mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 277b7f8300 | |||
| 158263e557 | |||
| d7948e34b2 | |||
| 9f42211993 | |||
| 65e62307bf | |||
| 469b332da2 | |||
| e434442f1e | |||
| 9344431bfc | |||
| 622ecbec1d | |||
| fa1382b289 | |||
| b8388e5a2a | |||
| 2bce12798f | |||
| f89e889d18 | |||
| 2b8a2d24b6 | |||
| b85eccdff5 | |||
| d924e655c0 | |||
| b932e4e2e9 | |||
| ca4a3e8c9a | |||
| b498521196 | |||
| 8a8cf2025d | |||
| ed7d47d9b8 | |||
| b5494cc04b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix missing geo data in site insights because of incorrect proxying of events.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add stable scroll gutter to search container
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix robots.txt preventing favicon from being indexed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support nested site section groups
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Show a toolbar for authenticated users with access to the current site.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add support for inline expressions rendering with visitor data on GBO side
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Expose a hook useVisitorSession to access the current user/visitor
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add scrollcontainer component
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Refactor section tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix OpenAPI response showing as JSON instead of YAML when it should
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Scope search across sections and variants
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"packages/expr": {
|
||||
"name": "@gitbook/expr",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
@@ -95,13 +95,14 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -109,7 +110,7 @@
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@opennextjs/cloudflare": "^1.8.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -142,6 +143,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -197,7 +199,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -233,7 +235,7 @@
|
||||
},
|
||||
"packages/react-contentkit": {
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -265,7 +267,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -302,7 +304,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.141.0",
|
||||
"@gitbook/api": "^0.142.0",
|
||||
"bidc": "^0.0.2",
|
||||
},
|
||||
"packages": {
|
||||
@@ -674,7 +676,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.141.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-KViAAUUStITbNGqDydUjZhxGdG4lJrS3ll8eQI5Tvs0j+Wvb4x1lxGvnm/LgtrhW+FoEgrFuRKZWVga1bI1Qmg=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.142.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Lq1IbepAykHNG8y0fBvC7hQj3i/f1XATX58wLYXWCL3W1x6Z9f6Rs5K2qCOONswJh3l2NrX3ujrbxx3D8goRdw=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -866,9 +868,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.7.6", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-l4UGkmaZaAjWER+PuZ/zNziMnrbf6oA9B1RUDKcyKsX+hEES1b7h6kLgpo4a4maf01M+k0yJUZQyF4sf+vI+Iw=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.7.7", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-rkBpNfBsq3XvbBh+VcNI9dvs0UkuuJH5I7SvVdPx12LudiErShL/9RoA7iF9tpkfRizcwGRIhsedrHKmcs9kqw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.7.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.6", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-g3d8LSpGGJzuCLuLYHAs7nGWFKK57qSOgxhpn3wwk6t/tXxsneXA3D5nbf6zLsAQQo8b6A8LDcT732U3rUXUeA=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.8.3", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.7", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-ftyZwi2Mh6gtUE2HuHfYfQ/GsJ0vkS5KrPmFEd3beBSCvDRUURDR0o3qSdF3DqS5WJIEloWwgkjxyFed3Mp12w=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -2432,6 +2434,12 @@
|
||||
|
||||
"mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="],
|
||||
|
||||
"motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -3762,8 +3770,6 @@
|
||||
|
||||
"@opennextjs/aws/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"@opennextjs/aws/yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="],
|
||||
|
||||
"@playwright/test/playwright": ["playwright@1.54.2", "", { "dependencies": { "playwright-core": "1.54.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -4352,6 +4358,10 @@
|
||||
|
||||
"minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="],
|
||||
|
||||
"motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.141.0",
|
||||
"@gitbook/api": "^0.142.0",
|
||||
"bidc": "^0.0.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.gmgn.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.spicychat.ai',
|
||||
contentBaseURL: 'https://docs.spicychat.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.spicychat.ai',
|
||||
// contentBaseURL: 'https://docs.spicychat.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
|
||||
@@ -26,8 +26,10 @@ export default class extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||
const startTime = Date.now();
|
||||
const middlewareRequest = new Request(request.url, request);
|
||||
middlewareRequest.headers.set('x-open-next-continent', request.cf?.continent || '');
|
||||
// - `Request`s are handled by the Next server
|
||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||
const reqOrResp = await middlewareHandler(middlewareRequest, this.env, this.ctx);
|
||||
if (reqOrResp instanceof Response) {
|
||||
const duration = Date.now() - startTime;
|
||||
const logMessage = formatLog(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -14,7 +15,8 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@opennextjs/cloudflare": "^1.8.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -39,6 +41,7 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
@@ -46,6 +49,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -71,10 +75,8 @@
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"zod": "^3"
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -159,7 +159,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -374,7 +374,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
@@ -4,19 +4,14 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AdaptiveVisitorClaimsData = {
|
||||
visitor: {
|
||||
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* In-memory cache of visitor claim readers keyed by contextId.
|
||||
*/
|
||||
const adaptiveVisitorReaderCache = new Map<
|
||||
string,
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
|
||||
>();
|
||||
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
@@ -52,7 +47,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return await res.json<AdaptiveVisitorClaimsData>();
|
||||
return await res.json<AdaptiveVisitorClaims>();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +59,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
return reader;
|
||||
}
|
||||
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
|
||||
|
||||
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './AdaptiveVisitorContextProvider';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
|
||||
unsigned: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AdaptiveVisitorClaims = {
|
||||
visitor: {
|
||||
claims: AdaptiveVisitorClaimsData;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Variables } from '@gitbook/api';
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* Return an evaluation context to evaluate expressions.
|
||||
*/
|
||||
export function createExpressionEvaluationContext(args: {
|
||||
visitorClaims: AdaptiveVisitorClaims | null;
|
||||
variables: {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
};
|
||||
}) {
|
||||
const { visitorClaims, variables } = args;
|
||||
return {
|
||||
...(visitorClaims ? visitorClaims : {}),
|
||||
space: {
|
||||
vars: variables.space ?? {},
|
||||
},
|
||||
...(variables.page
|
||||
? {
|
||||
page: {
|
||||
vars: variables.page ?? {},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,143 +1,63 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { AdminToolbarClient } from './AdminToolbarClient';
|
||||
import type { AdminToolbarContext } from './types';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
export interface AdminToolbarProps {
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
|
||||
function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'bottom-5',
|
||||
'left-1/2',
|
||||
'z-50',
|
||||
'transform',
|
||||
'-translate-x-1/2',
|
||||
'rounded-full',
|
||||
|
||||
'bg-tint-12/9',
|
||||
'dark:bg-tint-1/9',
|
||||
|
||||
'shadow-lg',
|
||||
'min-h-10',
|
||||
'min-w-40',
|
||||
'p-2',
|
||||
'max-w-md',
|
||||
'border-tint-12/1',
|
||||
'backdrop-blur-md'
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={null}>{props.children}</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
* Server component that determines what type of toolbar to show and passes data to client component
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
// Create a minimal context to avoid serializing and passing too many data to the client
|
||||
const minimalContext: AdminToolbarContext = {
|
||||
organizationId: context.organizationId,
|
||||
revisionId: context.revisionId,
|
||||
space: {
|
||||
id: context.space.id,
|
||||
revision: context.space.revision,
|
||||
urls: {
|
||||
app: context.space.urls.app,
|
||||
},
|
||||
},
|
||||
changeRequest: context.changeRequest
|
||||
? {
|
||||
id: context.changeRequest.id,
|
||||
number: context.changeRequest.number,
|
||||
subject: context.changeRequest.subject,
|
||||
revision: context.changeRequest.revision,
|
||||
updatedAt: context.changeRequest.updatedAt,
|
||||
createdBy: {
|
||||
displayName: context.changeRequest.createdBy.displayName,
|
||||
},
|
||||
urls: {
|
||||
app: context.changeRequest.urls.app,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
revision: {
|
||||
createdAt: context.revision.createdAt,
|
||||
urls: {
|
||||
app: context.revision.urls.app,
|
||||
},
|
||||
git: context.revision.git
|
||||
? {
|
||||
url: context.revision.git.url,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
site: {
|
||||
id: context.site.id,
|
||||
title: context.site.title,
|
||||
urls: {
|
||||
app: context.site.urls.app,
|
||||
published: context.site.urls.published,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<RevisionToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { space, changeRequest } = context;
|
||||
|
||||
if (!changeRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="code-branch" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</p>
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||
</p>
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<RefreshChangeRequestButton
|
||||
spaceId={space.id}
|
||||
changeRequestId={changeRequest.id}
|
||||
revisionId={changeRequest.revision}
|
||||
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
||||
/>
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { revision } = context;
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="code-commit" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
Revision created <DateRelative value={revision.createdAt} />
|
||||
</p>
|
||||
{revision.git ? (
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
{revision.git.message}
|
||||
</p>
|
||||
) : null}
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
{revision.git?.url ? (
|
||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||
<Icon
|
||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||
className="size-4"
|
||||
/>
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
return <AdminToolbarClient context={minimalContext} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarBody,
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSeparator,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import type { AdminToolbarClientProps } from './types';
|
||||
|
||||
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const visitorSession = useVisitorSession();
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<RevisionToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitorSession?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AuthenticatedUserToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
}
|
||||
|
||||
const author = changeRequest.createdBy.displayName;
|
||||
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: changeRequest.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix="Change request"
|
||||
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButtonGroup>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${changeRequest.urls.app}~/comments`,
|
||||
siteId: site.id,
|
||||
buttonId: 'comment',
|
||||
})}
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open production site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Open CR in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View change request in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: changeRequest.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'change-request',
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
}
|
||||
|
||||
const gitURL = revision.git?.url;
|
||||
const isGitHub = gitURL?.includes('github.com');
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
gitURL ? (
|
||||
`Open commit in ${gitProvider}`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Setup GitSync to edit using Git{' '}
|
||||
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||
<a
|
||||
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
href={gitURL}
|
||||
disabled={!gitURL}
|
||||
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
title="View this revision in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: revision.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'revision',
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gear"
|
||||
/>
|
||||
<ToolbarButton
|
||||
title="Customize in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
buttonId: 'customize',
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
<ToolbarButton
|
||||
title="Open insights in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
buttonId: 'insights',
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { href, motionValues, siteId } = props;
|
||||
const pagePath = useCurrentPagePath();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append utm parameters to a URL to track usage of the toolbar.
|
||||
*/
|
||||
function getToolbarHref({
|
||||
href,
|
||||
siteId,
|
||||
buttonId,
|
||||
}: { href: string; siteId: string; buttonId: string }) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'toolbar');
|
||||
url.searchParams.set('utm_campaign', siteId);
|
||||
url.searchParams.set('utm_content', buttonId);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
.svgLogo {
|
||||
--logo-fill: var(--color-neutral-100);
|
||||
--seg-A-color: #2782c4;
|
||||
--seg-B-color: #43b7f2;
|
||||
--seg-C-color: #8be2ff;
|
||||
--trace-color: #46474c;
|
||||
|
||||
|
||||
--T: 2s;
|
||||
shape-rendering: geometricPrecision;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
:global(html.dark) .svgLogo {
|
||||
--logo-fill: var(--color-neutral-800);
|
||||
--trace-color: #dedfe3;
|
||||
}
|
||||
|
||||
/* Base segment animation */
|
||||
.seg {
|
||||
opacity: 0;
|
||||
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
|
||||
segALen var(--T) linear;
|
||||
animation-delay: 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.segA {
|
||||
animation-name: segFade, segAMove, segALen;
|
||||
}
|
||||
|
||||
.segB {
|
||||
animation-name: segFade, segBMove, segBLen;
|
||||
}
|
||||
|
||||
.segC {
|
||||
animation-name: segFade, segCMove, segCLen;
|
||||
}
|
||||
|
||||
.trace {
|
||||
animation: traceFill var(--T) ease both 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes traceFill {
|
||||
0% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
95% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
100% {
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Segment A animation */
|
||||
@keyframes segAMove {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.5;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segALen {
|
||||
0% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment B animation */
|
||||
@keyframes segBMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.72;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segBLen {
|
||||
0%,
|
||||
50%,
|
||||
95% {
|
||||
stroke-dasharray: 0.2 0.8;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment C animation */
|
||||
@keyframes segCMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.92;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segCLen {
|
||||
0% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment fade animation - stays visible at end */
|
||||
@keyframes segFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.seg {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.trace {
|
||||
animation: none;
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type React from 'react';
|
||||
import styles from './AnimatedLogo.module.css';
|
||||
|
||||
export const AnimatedLogo: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
className={styles.svgLogo}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background trace */}
|
||||
<path
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
className={styles.trace}
|
||||
/>
|
||||
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segA)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.18 0.82"
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segB)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.20 0.80"
|
||||
strokeDashoffset={-0.18}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segC)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.22 0.78"
|
||||
strokeDashoffset={-0.38}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const dPath =
|
||||
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ToolbarButton } from './Toolbar';
|
||||
|
||||
// We don't show the button if the content has been updated 30s ago or less.
|
||||
const minInterval = 1000 * 30; // 5 minutes
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshChangeRequestButton(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { updatedAt } = props;
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const checkForUpdates = useCheckForContentUpdate(props);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Show the button if the content has been updated more than 30s ago.
|
||||
React.useEffect(() => {
|
||||
if (updatedAt < Date.now() - minInterval) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [updatedAt]);
|
||||
|
||||
// 30sec after being hidden, we show the button again
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, minInterval);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshContentButton(props: {
|
||||
refreshForUpdates: () => Promise<void>;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { refreshForUpdates, motionValues } = props;
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshForUpdates]);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh for latest changes"
|
||||
onClick={(event) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
motionValues={motionValues}
|
||||
icon="rotate"
|
||||
iconClassName={loading ? 'animate-spin' : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import type * as React from 'react';
|
||||
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
|
||||
import React from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
export function Toolbar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const [minified, setMinified] = React.useState(true);
|
||||
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
handleLoad();
|
||||
} else {
|
||||
window.addEventListener('load', handleLoad);
|
||||
return () => window.removeEventListener('load', handleLoad);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// After toolbar appears, wait then show the full content
|
||||
React.useEffect(() => {
|
||||
if (isReady) {
|
||||
const expandAfterTimeout = setTimeout(() => {
|
||||
setMinified(false);
|
||||
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
|
||||
|
||||
return () => clearTimeout(expandAfterTimeout);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
// Don't render anything until page is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-4',
|
||||
'text-sm',
|
||||
'px-4',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Tooltip label="Only visible to you">
|
||||
<motion.div
|
||||
onMouseEnter={() => setShowToolbarControls(true)}
|
||||
onMouseLeave={() => setShowToolbarControls(false)}
|
||||
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setMinified((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'border-tint-1/3',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
|
||||
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
|
||||
)}
|
||||
initial={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
boxShadow: minified
|
||||
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
|
||||
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
|
||||
}}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? props.children : null}
|
||||
|
||||
{!minified && showToolbarControls && (
|
||||
<MinifyButton setMinified={setMinified} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col gap-1">{props.children}</div>;
|
||||
return <div className="flex flex-col items-start px-3">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-row gap-2">{props.children}</div>;
|
||||
}
|
||||
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
|
||||
const { buttonMotionValues } = useMagnificationEffect({
|
||||
childrenCount: buttonChildren.length,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'size-11',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1', // Equal to text-tint-strong in dark mode
|
||||
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
|
||||
'hover:bg-tint-12',
|
||||
'dark:hover:bg-tint-1',
|
||||
'hover:shadow-lg',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
variants={toolbarEasings.parent}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{buttonChildren.map((child, index) => {
|
||||
const motionValues = buttonMotionValues[index];
|
||||
const childEl = child as React.ReactElement;
|
||||
return React.cloneElement(childEl, {
|
||||
key: index,
|
||||
motionValues,
|
||||
});
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
|
||||
motionValues?: {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
};
|
||||
icon: IconName;
|
||||
iconClassName?: string;
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolbarButton(props: ToolbarButtonProps) {
|
||||
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
|
||||
props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild}>
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'border-neutral-500',
|
||||
'outline-neutral-800',
|
||||
'outline-1',
|
||||
'border',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
'bg-tint-1/3',
|
||||
'hover:bg-tint-1/4',
|
||||
'dark:bg-tint-3',
|
||||
'dark:hover:bg-tint-1',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'shadow-1xs'
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSeparator() {
|
||||
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||
}
|
||||
|
||||
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs ">
|
||||
<ToolbarTitlePrefix title={props.prefix} />
|
||||
<ToolbarTitleSuffix title={props.suffix} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitlePrefix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(0)}
|
||||
className="font-light text-neutral-7 dark:text-neutral-3"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitleSuffix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-7 text-xxs dark:text-neutral-2"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
|
||||
return (
|
||||
<Tooltip label="Minify">
|
||||
<motion.div
|
||||
{...minifyButtonAnimation}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.setMinified(true);
|
||||
}}
|
||||
className={tcls(
|
||||
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
|
||||
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
|
||||
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
|
||||
)}
|
||||
>
|
||||
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './AdminToolbar';
|
||||
export * from './AdminToolbarClient';
|
||||
export * from './IframeWrapper';
|
||||
export * from './Toolbar';
|
||||
export * from './transitions';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Transition } from 'motion/react';
|
||||
|
||||
// Slight bounce but minimal overshoot esp. at the minimized state.
|
||||
const spring = {
|
||||
type: 'spring' as const,
|
||||
stiffness: 130,
|
||||
damping: 19,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
const parent = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delayChildren: 0.4,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const staggeringChild = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
},
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
} as Transition,
|
||||
},
|
||||
};
|
||||
|
||||
export const minifyButtonAnimation = {
|
||||
initial: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const getCopyVariants = (position: number) => {
|
||||
return {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -10,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
},
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
delay: position / 10 + 0.3,
|
||||
} as Transition,
|
||||
};
|
||||
};
|
||||
|
||||
export const toolbarEasings = {
|
||||
spring,
|
||||
parent,
|
||||
staggeringChild,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
|
||||
*/
|
||||
|
||||
export type MinimalChangeRequest = {
|
||||
id: string;
|
||||
number: number;
|
||||
subject: string | null;
|
||||
revision: string;
|
||||
updatedAt: string;
|
||||
createdBy: {
|
||||
displayName: string;
|
||||
};
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalRevision = {
|
||||
createdAt: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
git?: {
|
||||
url: string | undefined;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MinimalSpace = {
|
||||
id: string;
|
||||
revision: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalSite = {
|
||||
id: string;
|
||||
title: string;
|
||||
urls: {
|
||||
app: string;
|
||||
published: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminToolbarContext = {
|
||||
organizationId: string;
|
||||
revisionId: string;
|
||||
space: MinimalSpace;
|
||||
changeRequest: MinimalChangeRequest | null;
|
||||
revision: MinimalRevision;
|
||||
site: MinimalSite;
|
||||
};
|
||||
|
||||
export interface AdminToolbarClientProps {
|
||||
context: AdminToolbarContext;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { type MotionValue, motionValue } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonMotionValues {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
}
|
||||
|
||||
interface MagnificationConfig {
|
||||
/** Size of each button in pixels - used for spacing calculations */
|
||||
buttonSize?: number;
|
||||
/** Maximum scale factor for buttons when mouse is directly over them */
|
||||
maxScale?: number;
|
||||
/** Distance in pixels from mouse where buttons start scaling */
|
||||
influenceRadius?: number;
|
||||
/** Multiplier for spacing between buttons - higher values create more space */
|
||||
spacingMultiplier?: number;
|
||||
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
|
||||
scaleExponent?: number;
|
||||
/** Padding around container for mouse detection in pixels */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Required<MagnificationConfig> = {
|
||||
buttonSize: 32, // Standard button size for 32px (size-8) buttons
|
||||
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
|
||||
influenceRadius: 80, // ~2.5 button widths of influence
|
||||
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
|
||||
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
|
||||
padding: 10, // Small buffer zone around container edges
|
||||
};
|
||||
|
||||
// Helper functions for cleaner code
|
||||
const createMotionValues = (count: number): ButtonMotionValues[] =>
|
||||
Array.from({ length: count }, () => ({
|
||||
scale: motionValue(1),
|
||||
x: motionValue(0),
|
||||
}));
|
||||
|
||||
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
|
||||
motionValues.forEach(({ scale, x }) => {
|
||||
scale.set(1);
|
||||
x.set(0);
|
||||
});
|
||||
};
|
||||
|
||||
const captureButtonPositions = (buttons: HTMLElement[]) => {
|
||||
// Reset transforms to get original positions
|
||||
buttons.forEach((button) => {
|
||||
button.style.transform = '';
|
||||
});
|
||||
return buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const calculateScale = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
buttonCenterX: number,
|
||||
buttonCenterY: number,
|
||||
containerRect: DOMRect,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
// Calculate 2D distance from mouse to button center
|
||||
const deltaX = mouseX - buttonCenterX;
|
||||
const deltaY = mouseY - buttonCenterY;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
if (distance > config.influenceRadius) return 1;
|
||||
|
||||
// Calculate distance from container edge
|
||||
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||
const distanceFromEdgeY = Math.min(mouseY - containerRect.top, containerRect.bottom - mouseY);
|
||||
const distanceFromEdge = Math.min(distanceFromEdgeX, distanceFromEdgeY);
|
||||
|
||||
// Define the "heart" zone - 8px from center (16x16px total area)
|
||||
const heartZoneRadius = 8;
|
||||
|
||||
let baseScale: number;
|
||||
|
||||
if (distance <= heartZoneRadius) {
|
||||
// Within the heart zone - always at max scale
|
||||
baseScale = config.maxScale;
|
||||
} else {
|
||||
// Outside heart zone - scale from max to 1 based on distance
|
||||
const outerRadius = config.influenceRadius - heartZoneRadius;
|
||||
const outerDistance = distance - heartZoneRadius;
|
||||
const progress = Math.min(outerDistance / outerRadius, 1);
|
||||
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||
baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||
}
|
||||
|
||||
// Only apply entry transition if we're very close to the edge (within 10px)
|
||||
// This prevents the entry logic from interfering with normal scaling
|
||||
if (distanceFromEdge < 10) {
|
||||
const entryProgress = distanceFromEdge / 10;
|
||||
return 1 + (baseScale - 1) * entryProgress;
|
||||
}
|
||||
|
||||
return baseScale;
|
||||
};
|
||||
|
||||
const calculateSpacing = (
|
||||
buttonIndex: number,
|
||||
buttonEffects: Array<{ scale: number; translateX: number }>,
|
||||
positions: Array<{ left: number; width: number; top: number; height: number }>,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
const currentPos = positions[buttonIndex];
|
||||
if (!currentPos) return 0;
|
||||
|
||||
const buttonCenterX = currentPos.left + currentPos.width / 2;
|
||||
let leftPush = 0;
|
||||
let rightPush = 0;
|
||||
|
||||
buttonEffects.forEach((otherEffect, otherIndex) => {
|
||||
if (otherIndex === buttonIndex) return;
|
||||
|
||||
const otherPos = positions[otherIndex];
|
||||
if (!otherPos) return;
|
||||
|
||||
const otherCenterX = otherPos.left + otherPos.width / 2;
|
||||
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
|
||||
|
||||
// Only apply influence if close enough
|
||||
if (distanceBetween < config.influenceRadius * 1.5) {
|
||||
const influenceStrength = Math.max(
|
||||
0,
|
||||
1 - distanceBetween / (config.influenceRadius * 1.5)
|
||||
);
|
||||
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
|
||||
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
|
||||
|
||||
if (otherCenterX < buttonCenterX) {
|
||||
rightPush += pushAmount;
|
||||
} else {
|
||||
leftPush += pushAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rightPush - leftPush;
|
||||
};
|
||||
|
||||
export function useMagnificationEffect(props: {
|
||||
childrenCount: number;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
config?: MagnificationConfig;
|
||||
}) {
|
||||
const { childrenCount, containerRef, config } = props;
|
||||
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
|
||||
const originalPositionsRef = React.useRef<
|
||||
Array<{ left: number; width: number; top: number; height: number }>
|
||||
>([]);
|
||||
|
||||
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
|
||||
|
||||
if (buttons.length !== childrenCount) {
|
||||
console.error(
|
||||
`Button count (${buttons.length}) does not match children count (${childrenCount})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize motion values if button count changed
|
||||
if (buttonMotionValues.length !== buttons.length) {
|
||||
setButtonMotionValues(createMotionValues(buttons.length));
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const buttons = Array.from(
|
||||
container.querySelectorAll('.toolbar-button')
|
||||
) as HTMLElement[];
|
||||
|
||||
// Capture positions if needed
|
||||
if (originalPositionsRef.current.length !== buttons.length) {
|
||||
originalPositionsRef.current = captureButtonPositions(buttons);
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const { clientX: mouseX, clientY: mouseY } = event;
|
||||
|
||||
// Check if mouse is in range
|
||||
const isInRange =
|
||||
mouseX >= containerRect.left - finalConfig.padding &&
|
||||
mouseX <= containerRect.right + finalConfig.padding &&
|
||||
mouseY >= containerRect.top - finalConfig.padding &&
|
||||
mouseY <= containerRect.bottom + finalConfig.padding;
|
||||
|
||||
if (!isInRange) {
|
||||
resetMotionValues(buttonMotionValues);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate scales for all buttons
|
||||
const buttonEffects = buttons.map((_, index) => {
|
||||
const pos = originalPositionsRef.current[index];
|
||||
if (!pos) return { scale: 1, translateX: 0 };
|
||||
|
||||
const buttonCenterX = pos.left + pos.width / 2;
|
||||
const buttonCenterY = pos.top + pos.height / 2;
|
||||
const scale = calculateScale(
|
||||
mouseX,
|
||||
mouseY,
|
||||
buttonCenterX,
|
||||
buttonCenterY,
|
||||
containerRect,
|
||||
finalConfig
|
||||
);
|
||||
return { scale, translateX: 0 };
|
||||
});
|
||||
|
||||
// Calculate spacing for all buttons
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
effect.translateX = calculateSpacing(
|
||||
index,
|
||||
buttonEffects,
|
||||
originalPositionsRef.current,
|
||||
finalConfig
|
||||
);
|
||||
});
|
||||
|
||||
// Update motion values
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
const motionValue = buttonMotionValues[index];
|
||||
if (motionValue) {
|
||||
motionValue.scale.set(effect.scale);
|
||||
motionValue.x.set(effect.translateX);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
|
||||
|
||||
return { buttonMotionValues };
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
'use server';
|
||||
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
* Return true if the content has been updated.
|
||||
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
|
||||
*/
|
||||
export async function hasContentBeenUpdated(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: props.spaceId,
|
||||
changeRequestId: props.changeRequestId,
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
if (siteURLData.changeRequest) {
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: siteURLData.space,
|
||||
changeRequestId: siteURLData.changeRequest,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
}
|
||||
|
||||
const space = await getDataOrNull(
|
||||
context.dataFetcher.getSpace({
|
||||
spaceId: siteURLData.space,
|
||||
shareKey: siteURLData.shareKey,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
if (!space) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
return space.revision !== props.revisionId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,20 +5,71 @@ import React from 'react';
|
||||
import { hasContentBeenUpdated } from './server-actions';
|
||||
|
||||
/**
|
||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
||||
* Return a callback to check if a content has been updated and to refresh the page if it has.
|
||||
*/
|
||||
export function useCheckForContentUpdate(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
/**
|
||||
* ID of the revision of the content currently being displayed.
|
||||
*/
|
||||
revisionId: string;
|
||||
/**
|
||||
* How long after page load to check for updates.
|
||||
* @default 5min
|
||||
*/
|
||||
checkAfterLoad?: number;
|
||||
}) {
|
||||
const { spaceId, changeRequestId, revisionId } = props;
|
||||
|
||||
return React.useCallback(async () => {
|
||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
||||
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
|
||||
const [updated, setUpdated] = React.useState(false);
|
||||
|
||||
const checkForUpdates = React.useCallback(async () => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await hasContentBeenUpdated({ revisionId });
|
||||
setUpdated(result);
|
||||
return result;
|
||||
}, [revisionId, updated]);
|
||||
|
||||
const refreshForUpdates = React.useCallback(async () => {
|
||||
if (await checkForUpdates()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [spaceId, changeRequestId, revisionId]);
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Check for updates after the page has loaded
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
|
||||
checkForUpdates();
|
||||
}
|
||||
}, checkAfterLoad);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [checkForUpdates, checkAfterLoad]);
|
||||
|
||||
// Check for updates when the tab is becoming visible
|
||||
React.useEffect(() => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', callback);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', callback);
|
||||
};
|
||||
}, [checkForUpdates, updated]);
|
||||
|
||||
return { refreshForUpdates, updated };
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,11 +23,21 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const { block, style, inlines, inlineExprVariables } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
||||
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims,
|
||||
variables: inlineExprVariables,
|
||||
});
|
||||
const plainLines = useMemo(
|
||||
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
|
||||
[block, inlines, evaluateInlineExpression]
|
||||
);
|
||||
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
|
||||
@@ -80,7 +93,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
if (typeof window !== 'undefined') {
|
||||
setHighlighting(true);
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
highlight(block, inlines).then((lines) => {
|
||||
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +111,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
|
||||
// Otherwise if the block is not in viewport, we reset to the plain lines
|
||||
setLines(null);
|
||||
}, [isInViewport, block, inlines]);
|
||||
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
||||
|
||||
return (
|
||||
<CodeBlockRenderer
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
@@ -14,32 +16,64 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, isEstimatedOffscreen, context } = props;
|
||||
const inlines = getInlines(block);
|
||||
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
|
||||
let hasInlineExpression = false;
|
||||
|
||||
const richInlines: RenderedInline[] = inlines
|
||||
// Exclude inline expressions from rendered inline as they are rendered as code text once evaluated
|
||||
// and so need to be treated as plain code tokens.
|
||||
.filter((inline) => {
|
||||
if (inline.inline.type === 'expression') {
|
||||
hasInlineExpression = true;
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style="space-y-4"
|
||||
/>
|
||||
);
|
||||
})();
|
||||
return true;
|
||||
})
|
||||
.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style="space-y-4"
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return { inline, body };
|
||||
});
|
||||
return { inline, body };
|
||||
});
|
||||
|
||||
if (!isEstimatedOffscreen) {
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression) {
|
||||
// In v2, we render the code block server-side
|
||||
const lines = await highlight(block, richInlines);
|
||||
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
|
||||
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
const variables = context.contentContext
|
||||
? {
|
||||
space: context.contentContext?.revision.variables,
|
||||
page:
|
||||
'page' in context.contentContext
|
||||
? context.contentContext.page.variables
|
||||
: undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<ClientCodeBlock
|
||||
block={block}
|
||||
style={style}
|
||||
inlines={richInlines}
|
||||
inlineExprVariables={variables}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ export async function preloadHighlight(block: DocumentBlockCode) {
|
||||
*/
|
||||
export async function highlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[]
|
||||
inlines: RenderedInline[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): Promise<HighlightLine[]> {
|
||||
const langName = getBlockLang(block);
|
||||
|
||||
@@ -74,10 +77,10 @@ export async function highlight(
|
||||
// - TEMP : language is PowerShell or C++ and browser is Safari:
|
||||
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isn’t writable
|
||||
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
|
||||
return plainHighlight(block, inlines);
|
||||
return plainHighlight(block, inlines, options);
|
||||
}
|
||||
|
||||
const code = getPlainCodeBlock(block);
|
||||
const code = getPlainCodeBlock(block, undefined, options);
|
||||
|
||||
const highlighter = await getSingletonHighlighter({
|
||||
langs: [langName],
|
||||
@@ -255,11 +258,17 @@ function matchTokenAndInlines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
|
||||
function getPlainCodeBlock(
|
||||
code: DocumentBlockCode,
|
||||
inlines?: InlineIndexed[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
code.nodes.forEach((node, index) => {
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options);
|
||||
content += lineContent;
|
||||
|
||||
if (index < code.nodes.length - 1) {
|
||||
@@ -273,7 +282,10 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
|
||||
function getPlainCodeBlockLine(
|
||||
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
|
||||
index: number,
|
||||
inlines?: InlineIndexed[]
|
||||
inlines?: InlineIndexed[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
@@ -284,7 +296,12 @@ function getPlainCodeBlockLine(
|
||||
switch (node.type) {
|
||||
case 'annotation': {
|
||||
const start = index + content.length;
|
||||
content += getPlainCodeBlockLine(node, index + content.length, inlines);
|
||||
content += getPlainCodeBlockLine(
|
||||
node,
|
||||
index + content.length,
|
||||
inlines,
|
||||
options
|
||||
);
|
||||
const end = index + content.length;
|
||||
|
||||
if (inlines) {
|
||||
@@ -297,6 +314,19 @@ function getPlainCodeBlockLine(
|
||||
break;
|
||||
}
|
||||
case 'expression': {
|
||||
const start = index + content.length;
|
||||
const exprValue =
|
||||
options?.evaluateInlineExpression?.(node.data.expression) ?? '';
|
||||
content += exprValue;
|
||||
const end = start + exprValue.length;
|
||||
|
||||
if (inlines) {
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -9,9 +9,13 @@ import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'
|
||||
*/
|
||||
export function plainHighlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[]
|
||||
inlines: RenderedInline[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines);
|
||||
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
|
||||
if (node.object === 'text') {
|
||||
@@ -20,6 +24,14 @@ export function plainHighlight(
|
||||
content: getNodeText(node),
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'expression') {
|
||||
return {
|
||||
type: 'plain',
|
||||
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const inline = inlinesCopy.shift();
|
||||
return {
|
||||
type: 'annotation',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { Emoji } from './Emoji';
|
||||
import { InlineButton } from './InlineButton';
|
||||
import { InlineExpression } from './InlineExpression';
|
||||
import { InlineIcon } from './InlineIcon';
|
||||
import { InlineImage } from './InlineImage';
|
||||
import { InlineLink } from './InlineLink';
|
||||
@@ -51,9 +52,7 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
|
||||
case 'icon':
|
||||
return <InlineIcon {...contextProps} inline={inline} />;
|
||||
case 'expression':
|
||||
// The GitBook API should take care of evaluating expressions.
|
||||
// We should never need to render them.
|
||||
return null;
|
||||
return <InlineExpression {...contextProps} inline={inline} />;
|
||||
default:
|
||||
return nullIfNever(inline);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DocumentInlineExpression } from '@gitbook/api';
|
||||
import type { InlineProps } from '../Inline';
|
||||
import { InlineExpressionValue } from './InlineExpressionValue';
|
||||
|
||||
/**
|
||||
* Render an inline expression.
|
||||
*/
|
||||
export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
|
||||
const { context, inline } = props;
|
||||
|
||||
const { data } = inline;
|
||||
|
||||
const variables = context.contentContext
|
||||
? {
|
||||
space: context.contentContext?.revision.variables,
|
||||
page:
|
||||
'page' in context.contentContext
|
||||
? context.contentContext.page.variables
|
||||
: undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<InlineExpressionValue expression={data.expression} variables={variables} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useMemo } from 'react';
|
||||
import type { InlineExpressionVariables } from './types';
|
||||
import { useEvaluateInlineExpression } from './useEvaluateInlineExpression';
|
||||
|
||||
export function InlineExpressionValue(props: {
|
||||
expression: string;
|
||||
variables: InlineExpressionVariables;
|
||||
}) {
|
||||
const { expression, variables } = props;
|
||||
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims,
|
||||
variables,
|
||||
});
|
||||
|
||||
const result = useMemo(
|
||||
() => evaluateInlineExpression(expression),
|
||||
[expression, evaluateInlineExpression]
|
||||
);
|
||||
|
||||
return <>{result}</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './InlineExpression';
|
||||
export * from './types';
|
||||
export * from './useEvaluateInlineExpression';
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Variables } from '@gitbook/api';
|
||||
|
||||
export interface InlineExpressionVariables {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
type AdaptiveVisitorClaims,
|
||||
createExpressionEvaluationContext,
|
||||
} from '@/components/Adaptive';
|
||||
import type { Variables } from '@gitbook/api';
|
||||
import { ExpressionRuntime, formatExpressionResult } from '@gitbook/expr';
|
||||
|
||||
/**
|
||||
* Hook that returns a callback to evaluate an inline expression with visitor data
|
||||
* and space/page variables as context.
|
||||
*/
|
||||
export function useEvaluateInlineExpression(args: {
|
||||
visitorClaims: AdaptiveVisitorClaims | null;
|
||||
variables: {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
};
|
||||
}) {
|
||||
const { visitorClaims, variables } = args;
|
||||
const evaluateInlineExpression = React.useMemo(() => {
|
||||
const runtime = new ExpressionRuntime();
|
||||
const evaluationContext = createExpressionEvaluationContext({
|
||||
visitorClaims,
|
||||
variables,
|
||||
});
|
||||
|
||||
return (expression: string) => {
|
||||
try {
|
||||
return formatExpressionResult(
|
||||
runtime.evaluate(expression, evaluationContext) ?? ''
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to evaluate expression:', expression, err);
|
||||
return `{{${expression}}}`;
|
||||
}
|
||||
};
|
||||
}, [variables, visitorClaims]);
|
||||
|
||||
return evaluateInlineExpression;
|
||||
}
|
||||
@@ -95,8 +95,12 @@ export async function RecordCard(
|
||||
}}
|
||||
sizes={[
|
||||
{
|
||||
media: '(max-width: 640px)',
|
||||
width: view.cardSize === 'medium' ? 245 : 376,
|
||||
},
|
||||
{
|
||||
width: view.cardSize === 'medium' ? 490 : 752,
|
||||
},
|
||||
]}
|
||||
resize={context.contentContext?.imageResizer}
|
||||
className={tcls(
|
||||
|
||||
@@ -36,13 +36,9 @@ async function FooterLink(props: { link: CustomizationContentLink; context: GitB
|
||||
const { link, context } = props;
|
||||
const resolved = await resolveContentRef(link.to, context);
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={resolved.href}
|
||||
href={resolved?.href ?? '#'}
|
||||
className={tcls(
|
||||
'font-normal',
|
||||
'text-tint',
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { filterSectionsWithIndexableSpaces } from '@/lib/seo';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { flattenSectionsFromGroup } from '@/lib/utils';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||
import { HeaderLink } from './HeaderLink';
|
||||
@@ -15,7 +17,7 @@ import { TranslationsDropdown } from './SpacesDropdown';
|
||||
/**
|
||||
* Render the header for the space.
|
||||
*/
|
||||
export function Header(props: {
|
||||
export async function Header(props: {
|
||||
context: GitBookSiteContext;
|
||||
withTopHeader?: boolean;
|
||||
withVariants?: 'generic' | 'translations';
|
||||
@@ -23,6 +25,14 @@ export function Header(props: {
|
||||
const { context, withTopHeader, withVariants } = props;
|
||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||
|
||||
// Filter sections to only include those with indexable spaces
|
||||
const filteredSections = sections?.list
|
||||
? await filterSectionsWithIndexableSpaces(
|
||||
context,
|
||||
flattenSectionsFromGroup(sections.list).filter((s) => s.object === 'site-section')
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<header
|
||||
id="site-header"
|
||||
@@ -57,7 +67,7 @@ export function Header(props: {
|
||||
'theme-bold:shadow-tint-12/2'
|
||||
)}
|
||||
>
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
@@ -92,7 +102,6 @@ export function Header(props: {
|
||||
/>
|
||||
<HeaderLogo context={context} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
@@ -120,9 +129,28 @@ export function Header(props: {
|
||||
>
|
||||
<SearchContainer
|
||||
style={customization.styling.search}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) => s.space.language === siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={filteredSections.length > 1}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
encodeClientSiteSections(context, sections).current
|
||||
: undefined
|
||||
}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter((s) => s.space.language === siteSpace.space.language)
|
||||
.map((s) => s.id)}
|
||||
viewport={!withTopHeader ? 'mobile' : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -163,42 +191,18 @@ export function Header(props: {
|
||||
</div>
|
||||
|
||||
{sections ? (
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'overflow-x-auto',
|
||||
'no-scrollbar',
|
||||
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
|
||||
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
CONTAINER_STYLE,
|
||||
'grow',
|
||||
'flex',
|
||||
'items-end',
|
||||
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
|
||||
)}
|
||||
>
|
||||
{sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
|
||||
sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section
|
||||
<SiteSectionTabs
|
||||
sections={encodeClientSiteSections(context, sections)}
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
||||
{withVariants === 'translations' ? (
|
||||
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l">
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
/>
|
||||
) : null}
|
||||
{withVariants === 'translations' ? (
|
||||
<div className="site-background before:contents[] -mr-4 sm:-mr-6 md:-mr-8 sticky inset-y-0 right-0 z-10 ml-6 flex h-full items-center py-2 pr-4 before:mr-4 before:h-full before:border-tint before:border-l sm:pr-6 md:pr-8">
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SiteSectionTabs>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function HeaderLink(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!target || !link.to) {
|
||||
if (!link.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function HeaderLink(props: {
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown={false}
|
||||
href={target.href}
|
||||
href={target?.href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export type HeaderLinkNavItemProps = {
|
||||
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
href: string;
|
||||
href?: string;
|
||||
isDropdown: boolean;
|
||||
} & DropdownButtonProps<HTMLElement>;
|
||||
|
||||
@@ -165,7 +165,7 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
|
||||
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
href={href ?? '#'}
|
||||
className={getHeaderLinkClassName({ headerPreset })}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
|
||||
@@ -76,7 +76,7 @@ export function TranslationsDropdown(props: {
|
||||
siteSpaces={siteSpaces}
|
||||
variant="blank"
|
||||
className={tcls(
|
||||
'-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
|
||||
'-mx-2 bg-transparent px-2 md:py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
|
||||
hasEmojiPrefix
|
||||
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
|
||||
: '',
|
||||
|
||||
@@ -119,14 +119,11 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
};
|
||||
}
|
||||
|
||||
if (allEvents.length > 0) {
|
||||
if (enabled) {
|
||||
sendEvents({
|
||||
eventUrl,
|
||||
events: allEvents,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
if (allEvents.length > 0 && enabled) {
|
||||
sendEvents({
|
||||
eventUrl,
|
||||
events: allEvents,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,7 +131,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
flushEventsSync();
|
||||
}, 1500);
|
||||
|
||||
// Flush events once the visitor session is set
|
||||
// Flush pending events once the visitor session has been fetched
|
||||
React.useEffect(() => {
|
||||
if (visitorSession) {
|
||||
flushEventsSync();
|
||||
|
||||
@@ -12,6 +12,8 @@ const VISITORID_COOKIE = '__session';
|
||||
|
||||
type SessionVisitorResponse = {
|
||||
deviceId: string;
|
||||
userId?: undefined;
|
||||
organizationId?: undefined;
|
||||
};
|
||||
|
||||
type SessionUserResponse = SessionVisitorResponse & {
|
||||
@@ -30,7 +32,7 @@ const visitorSessionStore = createStore<{
|
||||
}));
|
||||
|
||||
/**
|
||||
* Fetch and provide the visitor session.
|
||||
* Fetch, synchronize the visitor sesion with GitBook.
|
||||
*/
|
||||
export function VisitorSessionProvider(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -81,18 +83,16 @@ async function fetchSession({
|
||||
};
|
||||
}
|
||||
|
||||
const existingTrackingCookie = getSessionCookie();
|
||||
const { existing, proposedId } = getSessionCookie();
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
if (existing) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existingTrackingCookie;
|
||||
return existing;
|
||||
}
|
||||
// No tracking deviceId set, we'll need to consolidate with the server.
|
||||
const proposed = generateRandomId();
|
||||
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposed);
|
||||
url.searchParams.set('proposed', proposedId);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
@@ -107,21 +107,24 @@ async function fetchSession({
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
return {
|
||||
deviceId: proposed,
|
||||
deviceId: proposedId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionCookie(): SessionResponse | null {
|
||||
function getSessionCookie(): { existing: SessionResponse | null; proposedId: string } {
|
||||
const proposed = generateRandomId();
|
||||
const value = getBrowserCookie(VISITORID_COOKIE);
|
||||
if (!(value && typeof value === 'string')) {
|
||||
return null;
|
||||
return { existing: null, proposedId: proposed };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as SessionResponse;
|
||||
return parsed;
|
||||
return { existing: parsed, proposedId: proposed };
|
||||
} catch {
|
||||
return { deviceId: value };
|
||||
// Ignore legacy __session cookie format
|
||||
// and we'll renew it with the server using the previous one as a proposed ID
|
||||
return { existing: null, proposedId: proposed };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,10 @@ export function PageBody(props: {
|
||||
blockStyle="page-api-block:ml-0"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
interface SearchContainerProps {
|
||||
/** The current site space id. */
|
||||
siteSpaceId: string;
|
||||
|
||||
/** The title of the current space. */
|
||||
spaceTitle: string;
|
||||
isMultiVariants: boolean;
|
||||
|
||||
/** The ids of all spaces in the current section. */
|
||||
siteSpaceIds: string[];
|
||||
|
||||
/** Whether there are sections on the site. */
|
||||
withSections: boolean;
|
||||
|
||||
/** The current section, displayed in search scope toggle. */
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
|
||||
/** Whether the current section has variants. */
|
||||
withVariants: boolean;
|
||||
|
||||
/** Whether any section on the site has variants. */
|
||||
withSiteVariants: boolean;
|
||||
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
viewport?: 'desktop' | 'mobile';
|
||||
@@ -30,7 +48,18 @@ interface SearchContainerProps {
|
||||
* Client component to render the search input and results.
|
||||
*/
|
||||
export function SearchContainer(props: SearchContainerProps) {
|
||||
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
|
||||
const {
|
||||
siteSpaceId,
|
||||
spaceTitle,
|
||||
section,
|
||||
withVariants,
|
||||
withSiteVariants,
|
||||
withSections,
|
||||
style,
|
||||
className,
|
||||
viewport,
|
||||
siteSpaceIds,
|
||||
} = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
|
||||
@@ -108,7 +137,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
}
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
@@ -148,7 +177,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
||||
query: value,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: true,
|
||||
}));
|
||||
};
|
||||
@@ -168,15 +197,22 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
{isMultiVariants && !showAsk ? (
|
||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||
{(withVariants || withSections) && !showAsk ? (
|
||||
<SearchScopeToggle
|
||||
section={section}
|
||||
spaceTitle={spaceTitle}
|
||||
withVariants={withVariants}
|
||||
withSiteVariants={withSiteVariants}
|
||||
withSections={withSections}
|
||||
/>
|
||||
) : null}
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
global={state?.global ?? false}
|
||||
scope={state?.scope ?? 'default'}
|
||||
siteSpaceId={siteSpaceId}
|
||||
siteSpaceIds={siteSpaceIds}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
@@ -194,7 +230,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
'@container overflow-y-scroll bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
|
||||
@@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import {
|
||||
type OrderedComputedResult,
|
||||
searchAllSiteContent,
|
||||
searchSiteSpaceContent,
|
||||
searchCurrentSiteSpaceContent,
|
||||
searchSpecificSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -50,12 +52,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, global, siteSpaceId } = props;
|
||||
const { children, query, scope, siteSpaceId, siteSpaceIds } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -133,9 +136,25 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(async () => {
|
||||
const results = await (global
|
||||
? searchAllSiteContent(query)
|
||||
: searchSiteSpaceContent(query));
|
||||
const results = await (() => {
|
||||
if (scope === 'all') {
|
||||
// Search all content on the site
|
||||
return searchAllSiteContent(query);
|
||||
}
|
||||
if (scope === 'default') {
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return searchCurrentSiteSpaceContent(query, siteSpaceId);
|
||||
}
|
||||
if (scope === 'extended') {
|
||||
// Search all variants of the current section
|
||||
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
|
||||
}
|
||||
if (scope === 'current') {
|
||||
// Search only the current section's current variant
|
||||
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
|
||||
}
|
||||
throw new Error(`Unhandled search scope: ${scope}`);
|
||||
})();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -158,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, global, trackEvent, withAI, siteSpaceId]);
|
||||
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAI) {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button } from '../primitives';
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||
* Only visible when the space is in a collection.
|
||||
*/
|
||||
export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
const { spaceTitle } = props;
|
||||
export function SearchScopeToggle(props: {
|
||||
spaceTitle: string;
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
withSections: boolean;
|
||||
}) {
|
||||
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
|
||||
const [state, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
@@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
|
||||
>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={!state.global}
|
||||
label={tString(language, 'search_scope_space', spaceTitle)}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={state.global}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{withSections ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Default` scope = current section's current variant + best match in other sections */}
|
||||
<SegmentedControlItem
|
||||
active={
|
||||
withSiteVariants
|
||||
? state.scope === 'default'
|
||||
: ['default', 'all'].includes(state.scope)
|
||||
}
|
||||
label={
|
||||
withSiteVariants
|
||||
? tString(language, 'search_scope_default')
|
||||
: tString(language, 'search_scope_all')
|
||||
}
|
||||
className={withSiteVariants ? '@max-md:basis-full' : ''}
|
||||
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
|
||||
onClick={() => setSearchState({ ...state, scope: 'default' })}
|
||||
/>
|
||||
|
||||
{/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */}
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'current' || state.scope === 'extended'}
|
||||
icon={section?.icon ?? 'crosshairs'}
|
||||
label={tString(language, 'search_scope_current', section?.title)}
|
||||
onClick={() => setSearchState({ ...state, scope: 'current' })}
|
||||
/>
|
||||
|
||||
{/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */}
|
||||
{withSiteVariants ? (
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'all'}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
icon="infinity"
|
||||
onClick={() => setSearchState({ ...state, scope: 'all' })}
|
||||
/>
|
||||
) : null}
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
{withVariants &&
|
||||
(!withSections || state.scope === 'current' || state.scope === 'extended') ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Current` scope = current section's current variant. `Default` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'current'
|
||||
: ['default', 'current'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_current', spaceTitle)}
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: withSections ? 'current' : 'default',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* `Extended` scope = all variants of the current section. `All` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'extended'
|
||||
: ['extended', 'all'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_extended')}
|
||||
onClick={() =>
|
||||
setSearchState({ ...state, scope: withSections ? 'extended' : 'all' })
|
||||
}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,18 +81,33 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
|
||||
/**
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
||||
export async function searchCurrentSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceId: string
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: siteURLData.siteSection
|
||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||
scope: { mode: 'current', siteSpaceId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a specific space.
|
||||
*/
|
||||
export async function searchSpecificSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceIds: string[]
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
scope: { mode: 'specific', siteSpaceIds },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export type SearchScope =
|
||||
/** Search all content on the site */
|
||||
| 'all'
|
||||
/** Search the current section's variant + matched/default variant for other sections */
|
||||
| 'default'
|
||||
/** Search all variants of the current section */
|
||||
| 'extended'
|
||||
/** Search only the current section's current variant */
|
||||
| 'current';
|
||||
|
||||
export interface SearchState {
|
||||
// URL-backed state
|
||||
query: string | null;
|
||||
ask: string | null;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
|
||||
// Local UI state
|
||||
open: boolean;
|
||||
@@ -18,7 +28,8 @@ export interface SearchState {
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsString,
|
||||
global: parseAsBoolean,
|
||||
scope: parseAsStringLiteral(['all', 'default', 'extended', 'current']).withDefault('default'),
|
||||
global: parseAsBoolean, // Legacy support for global=true
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -41,14 +52,21 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
history: 'replace',
|
||||
});
|
||||
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
React.useEffect(() => {
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
if (rawState?.ask === 'true' && rawState?.q) {
|
||||
// Convert legacy format: q=query&ask=true -> ask=query&q=null
|
||||
setRawState({
|
||||
q: null,
|
||||
ask: rawState.q,
|
||||
global: rawState.global,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy global=true
|
||||
if (rawState?.global === true) {
|
||||
setRawState({
|
||||
scope: 'all',
|
||||
global: null, // Remove the legacy parameter
|
||||
});
|
||||
}
|
||||
}, [rawState, setRawState]);
|
||||
@@ -65,7 +83,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
return {
|
||||
query: rawState.q,
|
||||
ask: rawState.ask,
|
||||
global: !!rawState.global,
|
||||
scope: rawState.scope,
|
||||
open,
|
||||
};
|
||||
}, [rawState, open]);
|
||||
@@ -85,14 +103,14 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
|
||||
if (update === null) {
|
||||
setIsOpen(false);
|
||||
return setRawState({ q: null, ask: null, global: null });
|
||||
return setRawState({ q: null, ask: null, scope: 'default' });
|
||||
}
|
||||
|
||||
setIsOpen(update.open);
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask,
|
||||
global: update.global ? true : null,
|
||||
scope: update.scope,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -126,7 +144,7 @@ export function useSearchLink(): (
|
||||
const searchParams = new URLSearchParams();
|
||||
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
|
||||
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
|
||||
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||
params.scope ? searchParams.set('scope', params.scope) : searchParams.delete('scope');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -137,7 +155,7 @@ export function useSearchLink(): (
|
||||
...prev,
|
||||
query: params.query !== undefined ? params.query : null,
|
||||
ask: params.ask !== undefined ? params.ask : null,
|
||||
global: params.global !== undefined ? params.global : false,
|
||||
scope: params.scope !== undefined ? params.scope : 'default',
|
||||
open: params.open !== undefined ? params.open : false,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -5,10 +5,10 @@ import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller';
|
||||
import { useIsMounted, useToggleAnimation } from '../hooks';
|
||||
import { findSectionInGroup } from '@/lib/utils';
|
||||
import { useToggleAnimation } from '../hooks';
|
||||
import { Link } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import type {
|
||||
ClientSiteSection,
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ClientSiteSections,
|
||||
} from './encodeClientSiteSections';
|
||||
|
||||
const MAX_ITEMS = 5; // If there are more sections than this, they'll be shown below the fold in a scrollview.
|
||||
const MAX_ITEMS = 6; // If there are more sections than this, they'll be shown below the fold in a scrollview.
|
||||
|
||||
/**
|
||||
* A list of items representing site sections for multi-section sites
|
||||
@@ -36,30 +36,34 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TOCScrollContainer
|
||||
<ScrollContainer
|
||||
orientation="vertical"
|
||||
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
|
||||
className="overflow-y-auto px-2 pb-4"
|
||||
className="pb-4"
|
||||
activeId={currentSection.id}
|
||||
>
|
||||
{sectionsAndGroups.map((item) => {
|
||||
if (item.object === 'site-section-group') {
|
||||
<div className="flex w-full flex-col px-2">
|
||||
{sectionsAndGroups.map((item) => {
|
||||
if (item.object === 'site-section-group') {
|
||||
return (
|
||||
<SiteSectionGroupItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteSectionGroupItem
|
||||
<SiteSectionListItem
|
||||
section={item}
|
||||
isActive={item.id === currentSection.id}
|
||||
key={item.id}
|
||||
group={item}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteSectionListItem
|
||||
section={item}
|
||||
isActive={item.id === currentSection.id}
|
||||
key={item.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TOCScrollContainer>
|
||||
})}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
@@ -69,20 +73,15 @@ export function SiteSectionListItem(props: {
|
||||
section: ClientSiteSection;
|
||||
isActive: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { section, isActive, className, ...otherProps } = props;
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item
|
||||
|
||||
const anchorRef = React.createRef<HTMLAnchorElement>();
|
||||
useScrollToActiveTOCItem({ anchorRef, isActive });
|
||||
const { section, isActive, className, style, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={anchorRef}
|
||||
href={section.url}
|
||||
aria-current={isActive && 'page'}
|
||||
id={section.id}
|
||||
className={tcls(
|
||||
'group/section-link',
|
||||
'flex',
|
||||
@@ -104,6 +103,7 @@ export function SiteSectionListItem(props: {
|
||||
: null,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
@@ -130,11 +130,12 @@ export function SiteSectionListItem(props: {
|
||||
export function SiteSectionGroupItem(props: {
|
||||
group: ClientSiteSectionGroup;
|
||||
currentSection: ClientSiteSection;
|
||||
level?: number;
|
||||
}) {
|
||||
const { group, currentSection } = props;
|
||||
const { group, currentSection, level = 0 } = props;
|
||||
|
||||
const hasDescendants = group.sections.length > 0;
|
||||
const isActiveGroup = group.sections.some((section) => section.id === currentSection.id);
|
||||
const hasDescendants = group.children.length > 0;
|
||||
const isActiveGroup = Boolean(findSectionInGroup(group, currentSection.id));
|
||||
const shouldOpen = hasDescendants && isActiveGroup;
|
||||
const [isOpen, setIsOpen] = React.useState(shouldOpen);
|
||||
|
||||
@@ -164,7 +165,7 @@ export function SiteSectionGroupItem(props: {
|
||||
className={tcls(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md straight-corners:rounded-none bg-tint-subtle text-lg text-tint leading-none shadow-tint shadow-xs ring-1 ring-tint-subtle transition-transform group-hover/section-link:scale-110 group-hover/section-link:ring-tint-hover group-active/section-link:scale-90 group-active/section-link:shadow-none contrast-more:text-tint-strong dark:shadow-none',
|
||||
isActiveGroup
|
||||
? 'bg-primary tint:bg-primary-solid text-primary tint:text-contrast-primary-solid shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary-strong contrast-more:ring-2 contrast-more:ring-primary'
|
||||
? 'bg-primary text-primary shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary-strong contrast-more:ring-2 contrast-more:ring-primary'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
@@ -219,14 +220,26 @@ export function SiteSectionGroupItem(props: {
|
||||
</button>
|
||||
{hasDescendants ? (
|
||||
<Descendants isVisible={isOpen}>
|
||||
{group.sections.map((section) => (
|
||||
<SiteSectionListItem
|
||||
section={section}
|
||||
isActive={section.id === currentSection.id}
|
||||
key={section.id}
|
||||
className="pl-5"
|
||||
/>
|
||||
))}
|
||||
{group.children.map((child) => {
|
||||
if (child.object === 'site-section') {
|
||||
return (
|
||||
<SiteSectionListItem
|
||||
section={child}
|
||||
isActive={child.id === currentSection.id}
|
||||
key={child.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteSectionGroupItem
|
||||
group={child}
|
||||
currentSection={currentSection}
|
||||
key={child.id}
|
||||
level={level + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Descendants>
|
||||
) : null}
|
||||
</>
|
||||
@@ -242,7 +255,7 @@ function Descendants(props: {
|
||||
return (
|
||||
<motion.div
|
||||
ref={scope}
|
||||
className={isVisible ? undefined : '[&_ul>li]:opacity-1'}
|
||||
className={isVisible ? 'pl-3' : 'pl-3 [&_ul>li]:opacity-1'}
|
||||
initial={isVisible ? show : hide}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,113 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import * as NavigationMenu from '@radix-ui/react-navigation-menu';
|
||||
import React from 'react';
|
||||
|
||||
import { Link } from '@/components/primitives';
|
||||
import { Button, DropdownChevron, Link } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { findSectionInGroup } from '@/lib/utils';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import type { ClientSiteSection, ClientSiteSections } from './encodeClientSiteSections';
|
||||
import type {
|
||||
ClientSiteSection,
|
||||
ClientSiteSectionGroup,
|
||||
ClientSiteSections,
|
||||
} from './encodeClientSiteSections';
|
||||
|
||||
const VIEWPORT_ITEM_WIDTH = 240; /* width of the tile (w-60) */
|
||||
const MIN_ITEMS_FOR_COLS = 4; /* number of items to switch to 2 columns */
|
||||
const SCREEN_OFFSET = 16; // 1rem
|
||||
const MAX_ITEMS_PER_COLUMN = 5; // number of items per column
|
||||
/**
|
||||
* A set of navigational links representing site sections for multi-section sites
|
||||
*/
|
||||
export function SiteSectionTabs(props: { sections: ClientSiteSections }) {
|
||||
export function SiteSectionTabs(props: {
|
||||
sections: ClientSiteSections;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
sections: { list: sectionsAndGroups, current: currentSection },
|
||||
sections: { list: structure, current: currentSection },
|
||||
className,
|
||||
children,
|
||||
} = props;
|
||||
const [value, setValue] = React.useState<string | null>();
|
||||
|
||||
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const [offset, setOffset] = React.useState<number | null>(null);
|
||||
const menuContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
const onNodeUpdate = (trigger: HTMLButtonElement | null, itemValue: string, size = 0) => {
|
||||
const padding = 16;
|
||||
const margin = -12; // Offsetting the menu container's negative margin
|
||||
const windowWidth = document.documentElement.clientWidth;
|
||||
const windowBuffer = 16; // constrain to within the window with some buffer on the left and right we don't want the menu to enter
|
||||
const viewportWidth =
|
||||
size < MIN_ITEMS_FOR_COLS
|
||||
? VIEWPORT_ITEM_WIDTH + padding
|
||||
: VIEWPORT_ITEM_WIDTH * 2 + padding;
|
||||
const minOffset = 0 - (menuContainerRef.current?.offsetLeft ?? 0) + margin;
|
||||
const maxOffset = minOffset + windowWidth - viewportWidth;
|
||||
const isMobile = useIsMobile(768);
|
||||
|
||||
if (windowWidth < 768) {
|
||||
// if the screen is small don't offset the menu
|
||||
setOffset(minOffset + windowBuffer);
|
||||
} else if (trigger && value === itemValue) {
|
||||
const position = minOffset + trigger?.getBoundingClientRect().left;
|
||||
setOffset(
|
||||
Math.min(maxOffset - windowBuffer, Math.max(minOffset + windowBuffer, position))
|
||||
);
|
||||
} else if (!value) {
|
||||
setOffset(null);
|
||||
React.useEffect(() => {
|
||||
const trigger = currentTriggerRef.current;
|
||||
if (!value || !trigger) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return sectionsAndGroups.length > 0 ? (
|
||||
const triggerWidth = trigger.getBoundingClientRect().width;
|
||||
const triggerLeft = trigger.getBoundingClientRect().left;
|
||||
setOffset(triggerLeft + triggerWidth / 2);
|
||||
}, [value]);
|
||||
|
||||
return structure.length > 0 ? (
|
||||
<NavigationMenu.Root
|
||||
aria-label="Sections"
|
||||
id="sections"
|
||||
className={tcls(
|
||||
CONTAINER_STYLE,
|
||||
'relative z-10 flex w-full flex-nowrap items-end',
|
||||
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]',
|
||||
className
|
||||
)}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
className="z-10 flex w-full flex-nowrap items-center"
|
||||
skipDelayDuration={500}
|
||||
>
|
||||
<div
|
||||
ref={menuContainerRef}
|
||||
className="-mx-3"
|
||||
// className="-mb-4 pb-4" /* Positive padding / negative margin allows the navigation menu indicator to show in a scroll view */
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls(
|
||||
'grow',
|
||||
'md:-ml-8 -ml-4 sm:-ml-6',
|
||||
!children ? 'md:-mr-8 -mr-4 sm:-mr-6' : ''
|
||||
)}
|
||||
activeId={currentSection.id}
|
||||
>
|
||||
<NavigationMenu.List className="center m-0 flex list-none gap-2 bg-transparent">
|
||||
{sectionsAndGroups.map((sectionOrGroup) => {
|
||||
const { id, title, icon } = sectionOrGroup;
|
||||
const isGroup = sectionOrGroup.object === 'site-section-group';
|
||||
<NavigationMenu.List
|
||||
className={tcls(
|
||||
'-mx-3 flex grow gap-2 bg-transparent',
|
||||
'pl-4 sm:pl-6 md:pl-8',
|
||||
!children ? 'pr-4 sm:pr-6 md:pr-8' : ''
|
||||
)}
|
||||
aria-label="Sections"
|
||||
id="sections"
|
||||
>
|
||||
{structure.map((structureItem) => {
|
||||
const { id, title, icon } = structureItem;
|
||||
const isGroup = structureItem.object === 'site-section-group';
|
||||
const isActiveGroup =
|
||||
isGroup &&
|
||||
Boolean(
|
||||
sectionOrGroup.sections.find((s) => s.id === currentSection.id)
|
||||
);
|
||||
Boolean(findSectionInGroup(structureItem, currentSection.id));
|
||||
const isActive = isActiveGroup || id === currentSection.id;
|
||||
return (
|
||||
<NavigationMenu.Item key={id} value={id}>
|
||||
{isGroup ? (
|
||||
sectionOrGroup.sections.length > 0 ? (
|
||||
<>
|
||||
<NavigationMenu.Trigger
|
||||
ref={(node) =>
|
||||
onNodeUpdate(
|
||||
node,
|
||||
id,
|
||||
sectionOrGroup.sections.length
|
||||
)
|
||||
<NavigationMenu.Item key={id} value={id} id={id}>
|
||||
{isGroup && structureItem.children.length > 0 ? (
|
||||
<>
|
||||
<NavigationMenu.Trigger
|
||||
asChild
|
||||
ref={value === id ? currentTriggerRef : undefined}
|
||||
onClick={(e) => {
|
||||
// Prevent clicking the trigger from closing when the viewport is open
|
||||
if (value === id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
asChild
|
||||
onClick={(e) => {
|
||||
if (value) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SectionGroupTab
|
||||
isActive={isActive}
|
||||
title={title}
|
||||
icon={icon as IconName}
|
||||
/>
|
||||
</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content className="absolute top-0 left-0 z-20 w-full motion-safe:data-[motion=from-end]:animate-enter-from-right motion-safe:data-[motion=from-start]:animate-enter-from-left motion-safe:data-[motion=to-end]:animate-exit-to-right motion-safe:data-[motion=to-start]:animate-exit-to-left md:w-max">
|
||||
<SectionGroupTileList
|
||||
sections={sectionOrGroup.sections}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
</NavigationMenu.Content>
|
||||
</>
|
||||
) : null
|
||||
}}
|
||||
>
|
||||
<SectionTab
|
||||
isActive={isActive}
|
||||
title={title}
|
||||
icon={icon as IconName}
|
||||
/>
|
||||
</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content>
|
||||
<SectionGroupTileList
|
||||
items={structureItem.children}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
</NavigationMenu.Content>
|
||||
</>
|
||||
) : (
|
||||
<NavigationMenu.Link asChild>
|
||||
<SectionTab
|
||||
url={sectionOrGroup.url}
|
||||
url={
|
||||
structureItem.object === 'site-section'
|
||||
? structureItem.url
|
||||
: undefined
|
||||
}
|
||||
isActive={isActive}
|
||||
title={title}
|
||||
icon={icon ? (icon as IconName) : undefined}
|
||||
@@ -117,26 +132,30 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) {
|
||||
</NavigationMenu.Item>
|
||||
);
|
||||
})}
|
||||
<NavigationMenu.Indicator
|
||||
className="fixed top-full z-50 flex h-3 items-end justify-center duration-150 motion-safe:transition-[width,transform] motion-safe:data-[state=hidden]:animate-fade-out motion-safe:data-[state=visible]:animate-fade-in"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="relative top-1/2 size-3 rotate-45 rounded-tl-sm border-tint-subtle border-t border-l bg-tint-base" />
|
||||
</NavigationMenu.Indicator>
|
||||
</NavigationMenu.List>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
{children}
|
||||
|
||||
<div
|
||||
className="absolute top-full flex transition-transform duration-200 ease-in-out"
|
||||
className="fixed top-full left-0 z-20 flex w-full"
|
||||
style={{
|
||||
display: offset === null ? 'none' : undefined,
|
||||
transform: offset ? `translateX(${offset}px) translateZ(0)` : 'translateZ(0)', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
|
||||
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
|
||||
}}
|
||||
>
|
||||
<NavigationMenu.Viewport
|
||||
className="relative mt-3 h-(--radix-navigation-menu-viewport-height) w-[calc(100vw-2rem)] origin-[top_center] overflow-hidden rounded-lg straight-corners:rounded-xs bg-tint-base depth-flat:shadow-none shadow-lg shadow-tint-10/6 ring-1 ring-tint-subtle duration-250 data-[state=closed]:duration-150 motion-safe:transition-[width,height,transform] motion-safe:data-[state=closed]:animate-scale-out motion-safe:data-[state=open]:animate-scale-in md:mx-0 md:w-(--radix-navigation-menu-viewport-width) dark:shadow-tint-1/6"
|
||||
className={tcls(
|
||||
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg transition-transform duration-250 ease-in-out',
|
||||
'-mt-0.5 w-full md:w-max',
|
||||
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
|
||||
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
|
||||
)}
|
||||
style={{
|
||||
translate:
|
||||
undefined /* don't move this to a Tailwind class as Radix renders viewport incorrectly for a few frames */,
|
||||
!isMobile && offset
|
||||
? `clamp(0px, calc(${offset}px - ${SCREEN_OFFSET}px - 50%), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
|
||||
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
|
||||
display: offset === null ? 'none' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,131 +164,172 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab representing a section
|
||||
* A tab representing a section or section group
|
||||
*/
|
||||
const SectionTab = React.forwardRef(function SectionTab(
|
||||
props: { isActive: boolean; title: string; icon?: IconName; url: string },
|
||||
props: { isActive: boolean; title: string; icon?: IconName; url?: string },
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { isActive, title, icon, url, ...rest } = props;
|
||||
const isGroup = url === undefined;
|
||||
return (
|
||||
<Link
|
||||
<Button
|
||||
ref={ref}
|
||||
size="medium"
|
||||
variant="blank"
|
||||
{...rest}
|
||||
icon={icon ? <SectionIcon isActive={isActive} icon={icon} /> : null}
|
||||
label={title}
|
||||
trailing={isGroup ? <DropdownChevron /> : null}
|
||||
active={isActive}
|
||||
className={tcls(
|
||||
'group relative my-2 flex select-none items-center justify-between circular-corners:rounded-full rounded-corners:rounded-xs px-3 py-1',
|
||||
'group/dropdown relative my-2 overflow-visible px-3 py-1',
|
||||
isActive
|
||||
? 'text-primary-subtle'
|
||||
: 'text-tint hover:bg-tint-hover hover:text-tint-strong'
|
||||
? 'after:contents-[] after:-bottom-2 bg-transparent text-primary-subtle after:absolute after:inset-x-3 after:h-0.5 after:bg-primary-9'
|
||||
: ''
|
||||
)}
|
||||
href={url}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2 truncate">
|
||||
{icon ? <SectionIcon isActive={isActive} icon={icon} /> : null}
|
||||
{title}
|
||||
</span>
|
||||
{isActive ? <ActiveTabIndicator /> : null}
|
||||
</Link>
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A tab representing a section group
|
||||
*/
|
||||
const SectionGroupTab = React.forwardRef(function SectionGroupTab(
|
||||
props: { isActive: boolean; title: string; icon?: IconName },
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { isActive, title, icon, ...rest } = props;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'group relative my-2 flex select-none items-center justify-between circular-corners:rounded-full rounded-sm straight-corners:rounded-none px-3 py-1 transition-colors hover:cursor-default',
|
||||
isActive
|
||||
? 'text-primary-subtle'
|
||||
: 'text-tint hover:bg-tint-hover hover:text-tint-strong'
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2 truncate">
|
||||
{icon ? <SectionIcon isActive={isActive} icon={icon as IconName} /> : null}
|
||||
{title}
|
||||
</span>
|
||||
{isActive ? <ActiveTabIndicator /> : null}
|
||||
<Icon
|
||||
aria-hidden
|
||||
icon="chevron-down"
|
||||
className="ms-1 size-3 shrink-0 opacity-6 transition-all group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Horizontal line indicating the active tab
|
||||
*/
|
||||
function ActiveTabIndicator() {
|
||||
return (
|
||||
<span className="-bottom-2 absolute inset-x-3 h-0.5 bg-primary-9 contrast-more:bg-primary-11" />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of section tiles grouped in the dropdown for a section group
|
||||
*/
|
||||
function SectionGroupTileList(props: {
|
||||
sections: ClientSiteSection[];
|
||||
items: (ClientSiteSection | ClientSiteSectionGroup)[];
|
||||
currentSection: ClientSiteSection;
|
||||
}) {
|
||||
const { sections, currentSection } = props;
|
||||
const { items, currentSection } = props;
|
||||
|
||||
// Separate non-grouped sections from grouped sections
|
||||
const sections = items.filter((item) => item.object === 'site-section');
|
||||
const groups = items.filter((item) => item.object === 'site-section-group');
|
||||
|
||||
const hasSections = sections.length > 0;
|
||||
const hasGroups = groups.length > 0;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={tcls(
|
||||
'grid w-full gap-1 p-2 sm:grid-cols-1 md:w-max',
|
||||
sections.length < MIN_ITEMS_FOR_COLS ? 'md:grid-cols-1' : 'md:grid-cols-2'
|
||||
<div className="flex flex-col md:flex-row md:items-start">
|
||||
{/* Non-grouped sections */}
|
||||
{hasSections && (
|
||||
<ul
|
||||
className={tcls(
|
||||
'flex grid-flow-row flex-col gap-x-2 gap-y-1 p-2 md:grid',
|
||||
hasGroups ? 'bg-tint-base' : ''
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.ceil(sections.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<SectionGroupTile
|
||||
key={section.id}
|
||||
child={section}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<SectionGroupTile
|
||||
key={section.id}
|
||||
section={section}
|
||||
isActive={section.id === currentSection.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Grouped sections */}
|
||||
{hasGroups && (
|
||||
<ul
|
||||
className={tcls(
|
||||
'flex grid-flow-col flex-col items-start gap-x-2 gap-y-4 p-2 md:grid md:gap-y-1',
|
||||
hasSections
|
||||
? 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<SectionGroupTile
|
||||
key={group.id}
|
||||
child={group}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A section tile shown in the dropdown for a section group
|
||||
*/
|
||||
function SectionGroupTile(props: { section: ClientSiteSection; isActive: boolean }) {
|
||||
const { section, isActive } = props;
|
||||
const { url, icon, title } = section;
|
||||
function SectionGroupTile(props: {
|
||||
child: ClientSiteSection | ClientSiteSectionGroup;
|
||||
currentSection: ClientSiteSection;
|
||||
}) {
|
||||
const { child, currentSection } = props;
|
||||
|
||||
if (child.object === 'site-section') {
|
||||
const { url, icon, title, description } = child;
|
||||
const isActive = child.id === currentSection.id;
|
||||
return (
|
||||
<li className="group/section-tile flex w-full shrink grow md:w-68">
|
||||
<Link
|
||||
href={url}
|
||||
className={tcls(
|
||||
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-3 py-2 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-active text-primary-strong'
|
||||
: 'text-tint-strong hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<div className="mb-auto flex grow items-center gap-2">
|
||||
{icon && (
|
||||
<div
|
||||
className={tcls(
|
||||
'-ml-1 self-start circular-corners:rounded-2xl rounded-corners:rounded-lg p-2 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-base text-primary-subtle'
|
||||
: 'bg-tint text-tint-strong group-hover/section-tile:bg-tint-base'
|
||||
)}
|
||||
>
|
||||
<SectionIcon isActive={isActive} icon={icon as IconName} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{title}
|
||||
{description && (
|
||||
<p className={isActive ? 'text-primary' : 'text-tint'}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle nested section group
|
||||
const { title, icon, children } = child;
|
||||
|
||||
return (
|
||||
<li className="flex w-full md:w-60">
|
||||
<Link
|
||||
href={url}
|
||||
className={tcls(
|
||||
'flex w-full select-none flex-col gap-1 rounded-sm straight-corners:rounded-none px-3 py-2 transition-colors hover:bg-tint-hover',
|
||||
isActive ? 'text-primary' : 'text-tint-strong'
|
||||
<li className="flex w-full shrink grow flex-col gap-1">
|
||||
<div className="mt-3 mb-2 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
|
||||
{icon && (
|
||||
<SectionIcon className="mt-0.5" isActive={false} icon={icon as IconName} />
|
||||
)}
|
||||
{title}
|
||||
</div>
|
||||
<ul
|
||||
className="flex grid-flow-row flex-col gap-x-2 gap-y-1 md:grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
{icon ? (
|
||||
<SectionIcon
|
||||
className="mt-[3px]"
|
||||
isActive={false}
|
||||
icon={icon as IconName}
|
||||
/>
|
||||
) : null}
|
||||
{title}
|
||||
</div>
|
||||
{section.description ? (
|
||||
<p className="text-tint-subtle">{section.description}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
{children.map((nestedChild) => (
|
||||
<SectionGroupTile
|
||||
key={nestedChild.id}
|
||||
child={nestedChild}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ClientSiteSection = Pick<
|
||||
};
|
||||
|
||||
export type ClientSiteSectionGroup = Pick<SiteSectionGroup, 'id' | 'title' | 'icon' | 'object'> & {
|
||||
sections: ClientSiteSection[];
|
||||
children: (ClientSiteSection | ClientSiteSectionGroup)[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,10 +30,10 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
|
||||
for (const item of list) {
|
||||
switch (item.object) {
|
||||
case 'site-section-group': {
|
||||
const sections = item.sections.map((section) => encodeSection(context, section));
|
||||
const children = encodeChildren(context, item.children);
|
||||
|
||||
// Skip empty groups
|
||||
if (sections.length === 0) {
|
||||
if (children.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
object: item.object,
|
||||
sections,
|
||||
children,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -61,6 +61,43 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
|
||||
};
|
||||
}
|
||||
|
||||
function encodeChildren(
|
||||
context: GitBookSiteContext,
|
||||
children: (SiteSection | SiteSectionGroup)[]
|
||||
): (ClientSiteSection | ClientSiteSectionGroup)[] {
|
||||
const clientChildren: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
|
||||
|
||||
for (const child of children) {
|
||||
switch (child.object) {
|
||||
case 'site-section': {
|
||||
clientChildren.push(encodeSection(context, child));
|
||||
break;
|
||||
}
|
||||
case 'site-section-group': {
|
||||
const nestedChildren = encodeChildren(context, child.children);
|
||||
|
||||
// Skip empty groups
|
||||
if (nestedChildren.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
clientChildren.push({
|
||||
id: child.id,
|
||||
title: child.title,
|
||||
icon: child.icon,
|
||||
object: child.object,
|
||||
children: nestedChildren,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(child, 'Unknown site section object type');
|
||||
}
|
||||
}
|
||||
|
||||
return clientChildren;
|
||||
}
|
||||
|
||||
function encodeSection(context: GitBookSiteContext, section: SiteSection) {
|
||||
return {
|
||||
id: section.id,
|
||||
|
||||
@@ -15,6 +15,8 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { filterSectionsWithIndexableSpaces } from '@/lib/seo';
|
||||
import { flattenSectionsFromGroup } from '@/lib/utils';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat } from '../AIChat';
|
||||
@@ -97,13 +99,21 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
/**
|
||||
* Render the entire layout of the space (header, table of contents, footer).
|
||||
*/
|
||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
export async function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const { context, children } = props;
|
||||
const { siteSpace, customization, sections, siteSpaces } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 1);
|
||||
// Filter sections to only include those with indexable spaces
|
||||
const withSections = Boolean(
|
||||
sections?.list
|
||||
? await filterSectionsWithIndexableSpaces(
|
||||
context,
|
||||
flattenSectionsFromGroup(sections.list).filter((s) => s.object === 'site-section')
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
const currentLanguage = getSpaceLanguage(context);
|
||||
const withVariants: 'generic' | 'translations' | undefined =
|
||||
@@ -180,9 +190,29 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={sections?.current}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
)
|
||||
.map((s) => s.id)}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
/>
|
||||
|
||||
@@ -40,11 +40,16 @@ export const variantClasses = {
|
||||
'bg-transparent',
|
||||
'text-tint',
|
||||
'border-0',
|
||||
'contrast-more:border',
|
||||
'shadow-none!',
|
||||
'translate-y-0!',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:text-tint-strong',
|
||||
'focus-visible:bg-tint-hover',
|
||||
'focus-visible:text-tint-strong',
|
||||
'data-[state=open]:bg-tint-hover',
|
||||
'data-[state=open]:text-tint-strong',
|
||||
'contrast-more:bg-tint-subtle',
|
||||
'hover:depth-subtle:translate-y-0',
|
||||
'disabled:text-tint/8',
|
||||
'disabled:bg-transparent',
|
||||
],
|
||||
@@ -80,9 +85,9 @@ export const variantClasses = {
|
||||
],
|
||||
};
|
||||
|
||||
const activeClasses = {
|
||||
export const activeClasses = {
|
||||
primary: 'bg-primary-solid-hover',
|
||||
blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
blank: 'bg-primary-active contrast-more:bg-primary-12 contrast-more:text-contrast-primary-12 disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active focus-visible:bg-primary-active focus-visible:text-primary-strong data-[state=open]:bg-primary-active data-[state=open]:text-primary-strong',
|
||||
secondary: 'bg-tint-active disabled:bg-tint-active',
|
||||
header: 'bg-header-link/3',
|
||||
};
|
||||
@@ -134,7 +139,7 @@ export const Button = React.forwardRef<
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em]')}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
@@ -154,6 +159,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
target={target}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
@@ -166,6 +172,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
/**
|
||||
* Display a date as a relative time.
|
||||
@@ -28,14 +29,11 @@ export function DateRelative(props: { value: string }) {
|
||||
const date = new Date(value);
|
||||
const diff = now - date.getTime();
|
||||
return (
|
||||
<time
|
||||
data-visual-test="transparent"
|
||||
suppressHydrationWarning={true}
|
||||
dateTime={value}
|
||||
title={date.toLocaleString()}
|
||||
>
|
||||
{formatDiff(language.locale, diff)}
|
||||
</time>
|
||||
<Tooltip label={date.toLocaleString()}>
|
||||
<time data-visual-test="transparent" suppressHydrationWarning={true} dateTime={value}>
|
||||
{formatDiff(language.locale, diff)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
@@ -146,10 +146,20 @@ export function DropdownMenuItem(
|
||||
active?: boolean;
|
||||
className?: ClassValue;
|
||||
children: React.ReactNode;
|
||||
leadingIcon?: IconName | React.ReactNode;
|
||||
} & LinkInsightsProps &
|
||||
RadixDropdownMenu.DropdownMenuItemProps
|
||||
) {
|
||||
const { children, active = false, href, className, insights, target, ...rest } = props;
|
||||
const {
|
||||
children,
|
||||
active = false,
|
||||
href,
|
||||
className,
|
||||
insights,
|
||||
target,
|
||||
leadingIcon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const itemClassName = tcls(
|
||||
'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||
@@ -161,10 +171,22 @@ export function DropdownMenuItem(
|
||||
className
|
||||
);
|
||||
|
||||
const icon = leadingIcon ? (
|
||||
typeof leadingIcon === 'string' ? (
|
||||
<Icon
|
||||
icon={leadingIcon as IconName}
|
||||
className={tcls('size-4 shrink-0', active ? 'text-primary' : 'text-tint-subtle')}
|
||||
/>
|
||||
) : (
|
||||
leadingIcon
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} asChild>
|
||||
<Link href={href} insights={insights} className={itemClassName} target={target}>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
</RadixDropdownMenu.Item>
|
||||
@@ -173,6 +195,7 @@ export function DropdownMenuItem(
|
||||
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} className={tcls('px-3 py-1', itemClassName, className)}>
|
||||
{icon}
|
||||
{children}
|
||||
</RadixDropdownMenu.Item>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
/**
|
||||
* A container that encapsulates a scrollable area with usability features.
|
||||
* - Faded edges when there is more content than the container can display.
|
||||
* - Buttons to advance the scroll position.
|
||||
* - Auto-scroll to the active item when it's initially active.
|
||||
*/
|
||||
export type ScrollContainerProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
/** The direction of the scroll container. */
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
|
||||
/** The ID of the active item to scroll to. */
|
||||
activeId?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function ScrollContainer(props: ScrollContainerProps) {
|
||||
const { children, className, orientation, activeId, ...rest } = props;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
const [scrollSize, setScrollSize] = React.useState(0);
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update scroll position on scroll using requestAnimationFrame
|
||||
const scrollListener: EventListener = () => {
|
||||
requestAnimationFrame(() => {
|
||||
setScrollPosition(
|
||||
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
|
||||
);
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', scrollListener);
|
||||
|
||||
// Update max scroll position using resize observer
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const containerEntry = entries.find((i) => i.target === containerRef.current);
|
||||
if (containerEntry) {
|
||||
setScrollSize(
|
||||
orientation === 'horizontal'
|
||||
? containerEntry.target.scrollWidth - containerEntry.target.clientWidth - 1
|
||||
: containerEntry.target.scrollHeight -
|
||||
containerEntry.target.clientHeight -
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', scrollListener);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [orientation]);
|
||||
|
||||
// Scroll to the active item
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !activeId) {
|
||||
return;
|
||||
}
|
||||
const activeItem = container.querySelector(`#${CSS.escape(activeId)}`);
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({
|
||||
inline: 'center',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
const scrollFurther = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const scrollBack = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls('group/scroll-container relative flex overflow-hidden', className)}
|
||||
{...rest}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink grow',
|
||||
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
|
||||
orientation === 'horizontal' ? 'overflow-x-scroll' : 'overflow-y-auto',
|
||||
scrollPosition > 0
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-l-from-[calc(100%-2rem)]'
|
||||
: 'mask-t-from-[calc(100%-2rem)]'
|
||||
: '',
|
||||
scrollPosition < scrollSize
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-r-from-[calc(100%-2rem)]'
|
||||
: 'mask-b-from-[calc(100%-2rem)]'
|
||||
: ''
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Scroll buttons back & forward */}
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
className={tcls(
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 left-0 ml-2'
|
||||
: '-translate-x-1/2! top-0 left-1/2 mt-2',
|
||||
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
|
||||
scrollPosition > 0
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none'
|
||||
)}
|
||||
onClick={scrollBack}
|
||||
label={tString(language, 'scroll_back')}
|
||||
/>
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
className={tcls(
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 right-0 mr-2'
|
||||
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
|
||||
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
|
||||
scrollPosition < scrollSize
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none'
|
||||
)}
|
||||
onClick={scrollFurther}
|
||||
label={tString(language, 'scroll_further')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
export function SegmentedControl(props: { children: React.ReactNode; className?: ClassValue }) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={tcls(
|
||||
'mb-2 flex flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint p-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControlItem(props: ButtonProps) {
|
||||
const { size = 'medium', className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="blank"
|
||||
size={size}
|
||||
className={tcls(
|
||||
'shrink grow justify-center whitespace-normal not-contrast-more:data-[active=true]:bg-tint-base',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export const de = {
|
||||
search_no_results_for: 'Keine Ergebnisse für "${1}".',
|
||||
search_no_results: 'Keine Ergebnisse',
|
||||
search_results_count: '${1} Ergebnisse',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle Inhalte',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle Inhalte',
|
||||
search_scope_default: 'Beste Match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Fragen',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_description: 'Finden Sie die Antwort mit ${1}',
|
||||
@@ -118,4 +120,6 @@ export const de = {
|
||||
copy_mcp_url: 'MCP-Server-URL kopieren',
|
||||
press_to_confirm: 'Drücke ${1} zum Bestätigen',
|
||||
tool_call_skipped: 'Übersprungen "${1}"',
|
||||
scroll_back: 'Zurück scrollen',
|
||||
scroll_further: 'Weiter scrollen',
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ export const en = {
|
||||
search_no_results_for: 'No results for "${1}".',
|
||||
search_no_results: 'No results',
|
||||
search_results_count: '${1} results',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'All content',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'All content',
|
||||
search_scope_default: 'Best match',
|
||||
search_scope_all: 'Everything',
|
||||
ask: 'Ask',
|
||||
search_ask: 'Ask "${1}"',
|
||||
search_ask_description: 'Find the answer with ${1}',
|
||||
@@ -115,4 +117,6 @@ export const en = {
|
||||
copy_mcp_url: 'Copy the MCP Server URL',
|
||||
press_to_confirm: 'Press ${1} to confirm',
|
||||
tool_call_skipped: 'Skipped "${1}"',
|
||||
scroll_back: 'Scroll back',
|
||||
scroll_further: 'Scroll further',
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ export const es: TranslationLanguage = {
|
||||
search_no_results_for: 'No hay resultados para "${1}".',
|
||||
search_no_results: 'No hay resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo el contenido',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo el contenido',
|
||||
search_scope_default: 'Mejor match',
|
||||
search_scope_all: 'Todo',
|
||||
ask: 'Preguntar',
|
||||
search_ask: 'Preguntar "${1}"',
|
||||
search_ask_description: 'Encuentra la respuesta con ${1}',
|
||||
@@ -119,4 +121,6 @@ export const es: TranslationLanguage = {
|
||||
copy_mcp_url: 'Copiar URL del servidor MCP',
|
||||
press_to_confirm: 'Presiona ${1} para confirmar',
|
||||
tool_call_skipped: 'Omitido "${1}"',
|
||||
scroll_back: 'Desplazar hacia atrás',
|
||||
scroll_further: 'Desplazar más',
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ export const fr = {
|
||||
search_no_results_for: 'Aucun résultat pour « ${1} ».',
|
||||
search_no_results: 'Aucun résultat',
|
||||
search_results_count: '${1} résultats',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Tous les contenus',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Tous les contenus',
|
||||
search_scope_default: 'Meilleur match',
|
||||
search_scope_all: 'Tout',
|
||||
ask: 'Poser une question',
|
||||
search_ask: 'Demander « ${1} »',
|
||||
search_ask_description: 'Trouvez la réponse avec ${1}',
|
||||
@@ -114,4 +116,6 @@ export const fr = {
|
||||
copy_mcp_url: "Copier l'URL du serveur MCP",
|
||||
press_to_confirm: 'Appuyez sur ${1} pour confirmer',
|
||||
tool_call_skipped: 'Ignoré "${1}"',
|
||||
scroll_back: "Défiler vers l'arrière",
|
||||
scroll_further: 'Défiler plus loin',
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ export const ja: TranslationLanguage = {
|
||||
search_no_results_for: '"${1}" の結果はありません。',
|
||||
search_no_results: '結果がありません',
|
||||
search_results_count: '${1}件の結果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '全てのコンテンツ',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '全てのコンテンツ',
|
||||
search_scope_default: '最適なマッチ',
|
||||
search_scope_all: 'すべて',
|
||||
ask: '質問する',
|
||||
search_ask: '"${1}" を質問する',
|
||||
search_ask_description: '${1}で答えを見つける',
|
||||
@@ -117,4 +119,6 @@ export const ja: TranslationLanguage = {
|
||||
copy_mcp_url: 'MCPサーバーのURLをコピー',
|
||||
press_to_confirm: '確認するには${1}を押してください',
|
||||
tool_call_skipped: '"${1}" をスキップしました',
|
||||
scroll_back: '戻る',
|
||||
scroll_further: '進む',
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ export const nl: TranslationLanguage = {
|
||||
search_no_results_for: 'Geen resultaten voor "${1}".',
|
||||
search_no_results: 'Geen resultaten',
|
||||
search_results_count: '${1} resultaten',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle inhoud',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle inhoud',
|
||||
search_scope_default: 'Beste match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Vragen',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met ${1}',
|
||||
@@ -117,4 +119,6 @@ export const nl: TranslationLanguage = {
|
||||
copy_mcp_url: 'Kopieer MCP-server URL',
|
||||
press_to_confirm: 'Druk op ${1} om te bevestigen',
|
||||
tool_call_skipped: '"${1}" overgeslagen',
|
||||
scroll_back: 'Terug scrollen',
|
||||
scroll_further: 'Verder scrollen',
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ export const no: TranslationLanguage = {
|
||||
search_no_results_for: 'Ingen resultater for "${1}".',
|
||||
search_no_results: 'Ingen resultater',
|
||||
search_results_count: '${1} resultater',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alt innhold',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alt innhold',
|
||||
search_scope_default: 'Beste treff',
|
||||
search_scope_all: 'Alt',
|
||||
ask: 'Spør',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_description: 'Finn svaret med ${1}',
|
||||
@@ -118,4 +120,6 @@ export const no: TranslationLanguage = {
|
||||
copy_mcp_url: 'Kopier MCP-server URL',
|
||||
press_to_confirm: 'Trykk ${1} for å bekrefte',
|
||||
tool_call_skipped: 'Hoppet over "${1}"',
|
||||
scroll_back: 'Rull tilbake',
|
||||
scroll_further: 'Rull videre',
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ export const pt_br = {
|
||||
search_no_results_for: 'Sem resultados para "${1}".',
|
||||
search_no_results: 'Sem resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo o conteúdo',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo o conteúdo',
|
||||
search_scope_default: 'Melhor match',
|
||||
search_scope_all: 'Tudo',
|
||||
ask: 'Perguntar',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_description: 'Encontre a resposta com ${1}',
|
||||
@@ -117,4 +119,6 @@ export const pt_br = {
|
||||
copy_mcp_url: 'Copiar URL do servidor MCP',
|
||||
press_to_confirm: 'Pressione ${1} para confirmar',
|
||||
tool_call_skipped: 'Pulado "${1}"',
|
||||
scroll_back: 'Rolar para trás',
|
||||
scroll_further: 'Rolar para frente',
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ export const ru = {
|
||||
search_no_results_for: 'Нет результатов для "${1}".',
|
||||
search_no_results: 'Нет результатов',
|
||||
search_results_count: '${1} — число результатов',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Все материалы',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Все материалы',
|
||||
search_scope_default: 'Лучший матч',
|
||||
search_scope_all: 'Всё',
|
||||
ask: 'Спросить',
|
||||
search_ask: 'Спросить "${1}"',
|
||||
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||
@@ -116,4 +118,6 @@ export const ru = {
|
||||
copy_mcp_url: 'Скопировать URL MCP-сервера',
|
||||
press_to_confirm: 'Нажмите ${1} для подтверждения',
|
||||
tool_call_skipped: 'Пропущен "${1}"',
|
||||
scroll_back: 'Прокрутить назад',
|
||||
scroll_further: 'Прокрутить дальше',
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ export const zh: TranslationLanguage = {
|
||||
search_no_results_for: '没有找到"${1}"的结果。',
|
||||
search_no_results: '没有找到结果',
|
||||
search_results_count: '${1} 个结果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '所有内容',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '所有内容',
|
||||
search_scope_default: '最佳匹配',
|
||||
search_scope_all: '全部',
|
||||
ask: '询问',
|
||||
search_ask: '询问"${1}"',
|
||||
search_ask_description: '利用${1}找到答案',
|
||||
@@ -114,4 +116,6 @@ export const zh: TranslationLanguage = {
|
||||
copy_mcp_url: '复制 MCP 服务器 URL',
|
||||
press_to_confirm: '按 ${1} 确认',
|
||||
tool_call_skipped: '已跳过 "${1}"',
|
||||
scroll_back: '向后滚动',
|
||||
scroll_further: '向前滚动',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import type { Link, Root } from 'mdast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import warnOnce from 'warn-once';
|
||||
import { checkIsAnchor, checkIsExternalURL } from './urls';
|
||||
|
||||
/**
|
||||
* Generic interface to generate links based on a given context.
|
||||
@@ -50,6 +54,21 @@ export interface GitBookLinker {
|
||||
* Generate a link (URL or path) for a GitBook content URL (url of another site)
|
||||
*/
|
||||
toLinkForContent(url: string): string;
|
||||
|
||||
/**
|
||||
* Create a new linker that overrides some options of the current one.
|
||||
*/
|
||||
fork(override: { spaceBasePath: string }): GitBookLinker;
|
||||
|
||||
/**
|
||||
* Site base path used to create this linker.
|
||||
*/
|
||||
siteBasePath: string;
|
||||
|
||||
/**
|
||||
* Space base path used to create this linker.
|
||||
*/
|
||||
spaceBasePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +93,23 @@ export function createLinker(
|
||||
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
|
||||
|
||||
const linker: GitBookLinker = {
|
||||
get siteBasePath() {
|
||||
return siteBasePath;
|
||||
},
|
||||
|
||||
get spaceBasePath() {
|
||||
return spaceBasePath;
|
||||
},
|
||||
|
||||
fork(override: {
|
||||
spaceBasePath: string;
|
||||
}) {
|
||||
return createLinker({
|
||||
...servedOn,
|
||||
spaceBasePath: override.spaceBasePath,
|
||||
});
|
||||
},
|
||||
|
||||
toPathInSpace(relativePath: string): string {
|
||||
return joinPaths(spaceBasePath, relativePath);
|
||||
},
|
||||
@@ -207,3 +243,34 @@ function joinPaths(prefix: string, path: string): string {
|
||||
function removeTrailingSlash(path: string): string {
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-writes the URL of every relative <a> link so it is expressed from the site-root.
|
||||
*/
|
||||
export function relativeToAbsoluteLinks(
|
||||
linker: GitBookLinker,
|
||||
tree: Root,
|
||||
currentPagePath: string
|
||||
): Root {
|
||||
const currentDir = path.posix.dirname(currentPagePath);
|
||||
|
||||
visit(tree, 'link', (node: Link) => {
|
||||
const original = node.url;
|
||||
|
||||
// Skip anchors, mailto:, http(s):, protocol-like, or already-rooted paths
|
||||
if (checkIsExternalURL(original) || checkIsAnchor(original) || original.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve against the current page’s directory and strip any leading “/” or "../"
|
||||
// Sometimes the path can be "../" if we are on the default section
|
||||
// but it means we are just at the root of the site.
|
||||
const pathInPage = path.posix
|
||||
.normalize(path.posix.join(currentDir, original))
|
||||
.replace(/^[\/\.]+/, '');
|
||||
|
||||
node.url = linker.toAbsoluteURL(linker.toPathInSpace(pathInPage));
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import { getIndexablePages } from '@/lib/sitemap';
|
||||
import { getMarkdownForPagesTree } from '@/routes/llms';
|
||||
import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api';
|
||||
import type { Root } from 'mdast';
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
|
||||
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';
|
||||
import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { frontmatter } from 'micromark-extension-frontmatter';
|
||||
import { gfm } from 'micromark-extension-gfm';
|
||||
import { remove } from 'unist-util-remove';
|
||||
import { type GitBookLinker, relativeToAbsoluteLinks } from './links';
|
||||
|
||||
type MarkdownResult = DataFetcherResponse<string>;
|
||||
|
||||
@@ -47,7 +52,7 @@ export async function getMarkdownForPage(
|
||||
return servePageGroup(context, page);
|
||||
}
|
||||
|
||||
const { data: markdown, error } = await context.dataFetcher.getRevisionPageMarkdown({
|
||||
const { data: rawMarkdown, error } = await context.dataFetcher.getRevisionPageMarkdown({
|
||||
spaceId: context.space.id,
|
||||
revisionId: context.revisionId,
|
||||
pageId: page.id,
|
||||
@@ -62,39 +67,54 @@ export async function getMarkdownForPage(
|
||||
};
|
||||
}
|
||||
|
||||
const tree = fromPageMarkdown({
|
||||
linker: context.linker,
|
||||
markdown: rawMarkdown,
|
||||
pagePath: page.path,
|
||||
});
|
||||
|
||||
// Handle empty document pages which have children
|
||||
if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) {
|
||||
if (isEmptyMarkdownPage(tree) && page.pages.length > 0) {
|
||||
return servePageGroup(context, page);
|
||||
}
|
||||
|
||||
return {
|
||||
data: markdown,
|
||||
};
|
||||
return { data: toPageMarkdown(tree) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown from a page, removing frontmatter and rewriting relative links to absolute links.
|
||||
* Returns the markdown AST that can be further processed or converted back to markdown using `toPageMarkdown`.
|
||||
*/
|
||||
export function fromPageMarkdown(args: {
|
||||
linker: GitBookLinker;
|
||||
markdown: string;
|
||||
pagePath: string;
|
||||
}): Root {
|
||||
const tree = fromMarkdown(args.markdown, {
|
||||
extensions: [frontmatter(['yaml']), gfm()],
|
||||
mdastExtensions: [frontmatterFromMarkdown(['yaml']), gfmFromMarkdown()],
|
||||
});
|
||||
|
||||
// Remove frontmatter
|
||||
remove(tree, 'yaml');
|
||||
|
||||
relativeToAbsoluteLinks(args.linker, tree, args.pagePath);
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown AST back to markdown.
|
||||
*/
|
||||
export function toPageMarkdown(tree: Root): string {
|
||||
return toMarkdown(tree, { extensions: [gfmToMarkdown()] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a page is empty.
|
||||
* A page is empty if it has no content or only a title.
|
||||
*/
|
||||
function isEmptyMarkdownPage(markdown: string): boolean {
|
||||
// Remove frontmatter
|
||||
const stripped = markdown
|
||||
.trim()
|
||||
.replace(/^---\n[\s\S]*?\n---\n?/g, '')
|
||||
.trim();
|
||||
|
||||
// Fast path: try to quickly detect obvious matches
|
||||
if (/^[ \t]*# .+$/m.test(stripped)) {
|
||||
// If there's a single heading line or empty lines, and no other content
|
||||
return (
|
||||
/^#{1,6} .+\s*$/.test(stripped) &&
|
||||
!/\n\S+/g.test(stripped.split('\n').slice(1).join('\n'))
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: parse with remark for safety
|
||||
const tree = unified().use(remarkParse).parse(stripped) as Root;
|
||||
|
||||
function isEmptyMarkdownPage(tree: Root): boolean {
|
||||
let seenHeading = false;
|
||||
|
||||
for (const node of tree.children) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { type RevisionPageDocument, type RevisionPageGroup, SiteVisibility } from '@gitbook/api';
|
||||
import { resolveFirstDocument } from '@/lib/pages';
|
||||
import {
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
SiteVisibility,
|
||||
type Space,
|
||||
} from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
/**
|
||||
@@ -45,3 +51,87 @@ export async function isSiteIndexable(context: GitBookSiteContext) {
|
||||
function shouldIndexVisibility(visibility: SiteVisibility) {
|
||||
return visibility === SiteVisibility.Public;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the first page of a site space is indexable.
|
||||
*/
|
||||
export async function isFirstPageIndexable(
|
||||
context: GitBookSiteContext,
|
||||
space: Space
|
||||
): Promise<boolean> {
|
||||
// Get the revision for the site space
|
||||
const revision = await context.dataFetcher.getRevision({
|
||||
spaceId: space.id,
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
if (revision.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the first document page in the revision
|
||||
const firstDocument = resolveFirstDocument(revision.data.pages, []);
|
||||
|
||||
if (!firstDocument) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first page is indexable
|
||||
return isPageIndexable(firstDocument.ancestors, firstDocument.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a section has any site spaces with indexable first pages.
|
||||
* This function actually checks the indexability by calling the async function.
|
||||
*/
|
||||
export async function hasIndexableSpaces(
|
||||
context: GitBookSiteContext,
|
||||
section: { siteSpaces: Array<{ space: Space }> }
|
||||
): Promise<boolean> {
|
||||
// Check if any site space in the section has an indexable first page
|
||||
const results = await Promise.allSettled(
|
||||
section.siteSpaces.map((siteSpace) => isFirstPageIndexable(context, siteSpace.space))
|
||||
);
|
||||
|
||||
// Return true if any of the checks succeeded and returned true
|
||||
return results.some((result) => result.status === 'fulfilled' && result.value === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sections to only include those that have at least one indexable site space.
|
||||
*/
|
||||
export async function filterSectionsWithIndexableSpaces<
|
||||
T extends { siteSpaces: Array<{ space: Space }> },
|
||||
>(context: GitBookSiteContext, sections: T[]): Promise<T[]> {
|
||||
const filteredSections: T[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
const hasIndexable = await hasIndexableSpaces(context, section);
|
||||
if (hasIndexable) {
|
||||
filteredSections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredSections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compute indexable status for all site spaces in sections.
|
||||
* This should be called during context creation to avoid async operations in components.
|
||||
*/
|
||||
export async function computeSectionsIndexableStatus(
|
||||
context: GitBookSiteContext,
|
||||
sections: Array<{ siteSpaces: Array<{ space: Space }> }>
|
||||
): Promise<Array<{ siteSpaces: Array<{ space: Space; indexable: boolean }> }>> {
|
||||
return Promise.all(
|
||||
sections.map(async (section) => ({
|
||||
...section,
|
||||
siteSpaces: await Promise.all(
|
||||
section.siteSpaces.map(async (siteSpace) => ({
|
||||
...siteSpace,
|
||||
indexable: await isFirstPageIndexable(context, siteSpace.space),
|
||||
}))
|
||||
),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||
import { joinPath } from './paths';
|
||||
import { flattenSectionsFromGroup } from './utils';
|
||||
|
||||
/**
|
||||
* Get all sections from a site structure.
|
||||
@@ -22,7 +23,9 @@ export function getSiteStructureSections(
|
||||
return siteStructure.type === 'sections'
|
||||
? ignoreGroups
|
||||
? siteStructure.structure.flatMap((item) =>
|
||||
item.object === 'site-section-group' ? item.sections : item
|
||||
item.object === 'site-section-group'
|
||||
? flattenSectionsFromGroup<SiteSection | SiteSectionGroup>(item.children)
|
||||
: item
|
||||
)
|
||||
: siteStructure.structure
|
||||
: [];
|
||||
@@ -41,7 +44,9 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) {
|
||||
return section.siteSpaces;
|
||||
}
|
||||
|
||||
return section.sections.flatMap((subSection) => subSection.siteSpaces);
|
||||
return flattenSectionsFromGroup<SiteSection | SiteSectionGroup>(section.children)
|
||||
.filter((subSection): subSection is SiteSection => subSection.object === 'site-section')
|
||||
.flatMap((subSection) => subSection.siteSpaces);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,13 +85,13 @@ export function findSiteSpaceBy(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const found = findSiteSpaceByIdInSections(sectionOrGroup.sections, predicate);
|
||||
const found = findSiteSpaceByIdInGroupChildren(
|
||||
sectionOrGroup.children,
|
||||
predicate,
|
||||
sectionOrGroup
|
||||
);
|
||||
if (found) {
|
||||
return {
|
||||
siteSpace: found.siteSpace,
|
||||
siteSection: found.siteSection,
|
||||
siteSectionGroup: sectionOrGroup,
|
||||
};
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,14 +141,30 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace:
|
||||
return siteSpacePath;
|
||||
}
|
||||
|
||||
function findSiteSpaceByIdInSections(
|
||||
sections: SiteSection[],
|
||||
predicate: (siteSpace: SiteSpace) => boolean
|
||||
): { siteSpace: SiteSpace; siteSection: SiteSection } | null {
|
||||
for (const siteSection of sections) {
|
||||
const siteSpace = siteSection.siteSpaces.find(predicate) ?? null;
|
||||
if (siteSpace) {
|
||||
return { siteSpace, siteSection };
|
||||
function findSiteSpaceByIdInGroupChildren(
|
||||
children: (SiteSection | SiteSectionGroup)[],
|
||||
predicate: (siteSpace: SiteSpace) => boolean,
|
||||
parentGroup: SiteSectionGroup
|
||||
): {
|
||||
siteSpace: SiteSpace;
|
||||
siteSection: SiteSection;
|
||||
siteSectionGroup: SiteSectionGroup;
|
||||
} | null {
|
||||
for (const child of children) {
|
||||
if (child.object === 'site-section') {
|
||||
const siteSpace = child.siteSpaces.find(predicate) ?? null;
|
||||
if (siteSpace) {
|
||||
return {
|
||||
siteSpace,
|
||||
siteSection: child,
|
||||
siteSectionGroup: parentGroup,
|
||||
};
|
||||
}
|
||||
} else if (child.object === 'site-section-group') {
|
||||
const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { headers as nextHeaders } from 'next/headers';
|
||||
import { GITBOOK_API_URL, GITBOOK_DISABLE_TRACKING } from './env';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_DISABLE_TRACKING } from './env';
|
||||
|
||||
/**
|
||||
* Return true if events should be tracked on the site.
|
||||
@@ -28,6 +28,17 @@ export function shouldTrackEvents(headers?: Awaited<ReturnType<typeof nextHeader
|
||||
export async function serveProxyAnalyticsEvent(req: Request) {
|
||||
const requestURL = new URL(req.url);
|
||||
|
||||
// Fill geolocation data from request headers either from OpenNext or Vercel
|
||||
const country =
|
||||
req.headers.get('x-open-next-country') || req.headers.get('x-vercel-ip-country');
|
||||
const latitude =
|
||||
req.headers.get('x-open-next-latitude') || req.headers.get('x-vercel-ip-latitude');
|
||||
const longitude =
|
||||
req.headers.get('x-open-next-longitude') || req.headers.get('x-vercel-ip-longitude');
|
||||
// OpenNext doesn't provide continent info, we add it manually in our custom worker
|
||||
const continent =
|
||||
req.headers.get('x-open-next-continent') || req.headers.get('x-vercel-ip-continent');
|
||||
|
||||
const org = requestURL.searchParams.get('o');
|
||||
const site = requestURL.searchParams.get('s');
|
||||
if (!org || !site) {
|
||||
@@ -36,11 +47,17 @@ export async function serveProxyAnalyticsEvent(req: Request) {
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
const url = new URL(`${GITBOOK_API_URL}/v1/orgs/${org}/sites/${site}/insights/events`);
|
||||
|
||||
// We make the request to the public API URL to ensure the request is properly enriched by the router..
|
||||
const url = new URL(`${GITBOOK_API_PUBLIC_URL}/v1/orgs/${org}/sites/${site}/insights/events`);
|
||||
return await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(country ? { 'x-location-country': country } : {}),
|
||||
...(latitude ? { 'x-location-latitude': latitude } : {}),
|
||||
...(longitude ? { 'x-location-longitude': longitude } : {}),
|
||||
...(continent ? { 'x-location-continent': continent } : {}),
|
||||
},
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
@@ -78,3 +78,34 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
|
||||
socialPreview: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flatten all sections from nested groups
|
||||
*/
|
||||
export function flattenSectionsFromGroup<T extends { id: string; object: string; children?: T[] }>(
|
||||
children: T[]
|
||||
): T[] {
|
||||
const sections: T[] = [];
|
||||
|
||||
for (const child of children) {
|
||||
if (child.object === 'site-section') {
|
||||
sections.push(child);
|
||||
} else if (child.object === 'site-section-group' && child.children) {
|
||||
sections.push(...flattenSectionsFromGroup(child.children));
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find a section by ID within a group and its nested children
|
||||
*/
|
||||
export function findSectionInGroup<T extends { id: string; object: string; children?: T[] }>(
|
||||
group: { children: T[] },
|
||||
sectionId: string
|
||||
): T | null {
|
||||
return (
|
||||
flattenSectionsFromGroup(group.children).find((section) => section.id === sectionId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import type { SiteSpace } from '@gitbook/api';
|
||||
|
||||
import { streamMarkdownFromSiteSpaces } from './llms-full';
|
||||
|
||||
function createMockLinker(args?: { spaceBasePath?: string }) {
|
||||
return {
|
||||
toAbsoluteURL: mock((path: string) => `https://example.com${path}`),
|
||||
toPathInSite: mock((path: string) => `/site/${args?.spaceBasePath ?? ''}${path}`),
|
||||
fork: (args: { spaceBasePath: string }) => createMockLinker(args),
|
||||
};
|
||||
}
|
||||
|
||||
describe('streamMarkdownFromSiteSpaces', () => {
|
||||
// Test with real mocks of the dependencies
|
||||
it('processes pages correctly with pagination', async () => {
|
||||
@@ -65,13 +73,9 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
),
|
||||
};
|
||||
|
||||
const mockLinker = {
|
||||
toPathInSite: mock((path: string) => `/test/${path}`),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: mockLinker,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
@@ -132,7 +136,7 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: { toPathInSite: mock((path: string) => `/${path}`) },
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
@@ -181,13 +185,9 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
getRevisionPageMarkdown: mock(() => Promise.resolve({ data: 'content\n' })),
|
||||
};
|
||||
|
||||
const mockLinker = {
|
||||
toPathInSite: mock((path: string) => `/site/${path}`),
|
||||
};
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: mockLinker,
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
@@ -287,7 +287,7 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: { toPathInSite: mock((path: string) => `/${path}`) },
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpaces: SiteSpace[] = [
|
||||
@@ -336,7 +336,7 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: { toPathInSite: mock((path: string) => `/${path}`) },
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
@@ -411,7 +411,7 @@ describe('streamMarkdownFromSiteSpaces', () => {
|
||||
|
||||
const mockContext: GitBookSiteContext = {
|
||||
dataFetcher: mockDataFetcher,
|
||||
linker: { toPathInSite: mock((path: string) => `/${path}`) },
|
||||
linker: createMockLinker(),
|
||||
} as unknown as GitBookSiteContext;
|
||||
|
||||
const mockSiteSpace: SiteSpace = {
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import path from 'node:path';
|
||||
import { type GitBookSiteContext, checkIsRootSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { getIndexablePages } from '@/lib/sitemap';
|
||||
import { getSiteStructureSections } from '@/lib/sites';
|
||||
import { checkIsAnchor, checkIsExternalURL } from '@/lib/urls';
|
||||
import type { RevisionPageDocument, SiteSection, SiteSpace } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import type { Link, Paragraph, Root } from 'mdast';
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
|
||||
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';
|
||||
import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
import { frontmatter } from 'micromark-extension-frontmatter';
|
||||
import { gfm } from 'micromark-extension-gfm';
|
||||
import type { Paragraph } from 'mdast';
|
||||
import { pMapIterable } from 'p-map';
|
||||
import { remove } from 'unist-util-remove';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// We limit the concurrency to 100 to avoid reaching limit with concurrent requests
|
||||
// or file descriptor limits.
|
||||
@@ -146,7 +137,7 @@ export async function streamMarkdownFromSiteSpaces(
|
||||
allPages.push({
|
||||
page,
|
||||
siteSpace,
|
||||
basePath: joinPath(basePath, siteSpace.path),
|
||||
basePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -201,14 +192,14 @@ async function getMarkdownForPage(
|
||||
})
|
||||
);
|
||||
|
||||
const tree = fromMarkdown(pageMarkdown, {
|
||||
extensions: [frontmatter(['yaml']), gfm()],
|
||||
mdastExtensions: [frontmatterFromMarkdown(['yaml']), gfmFromMarkdown()],
|
||||
const tree = fromPageMarkdown({
|
||||
linker: context.linker.fork({
|
||||
spaceBasePath: joinPath(context.linker.siteBasePath, basePath),
|
||||
}),
|
||||
markdown: pageMarkdown,
|
||||
pagePath: page.path,
|
||||
});
|
||||
|
||||
// Remove frontmatter
|
||||
remove(tree, 'yaml');
|
||||
|
||||
if (page.description) {
|
||||
// The first node is the page title as a H1, we insert the description as a paragraph
|
||||
// after it.
|
||||
@@ -219,40 +210,7 @@ async function getMarkdownForPage(
|
||||
tree.children.splice(1, 0, descriptionNode);
|
||||
}
|
||||
|
||||
// Rewrite relative links to absolute links
|
||||
transformLinks(context, tree, { currentPagePath: page.path, basePath });
|
||||
const markdown = toPageMarkdown(tree);
|
||||
|
||||
const markdown = toMarkdown(tree, { extensions: [gfmToMarkdown()] });
|
||||
return `${markdown}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-writes the URL of every relative <a> link so it is expressed from the site-root.
|
||||
*/
|
||||
export function transformLinks(
|
||||
context: GitBookSiteContext,
|
||||
tree: Root,
|
||||
options: { currentPagePath: string; basePath: string }
|
||||
): Root {
|
||||
const { linker } = context;
|
||||
const { currentPagePath, basePath } = options;
|
||||
const currentDir = path.posix.dirname(currentPagePath);
|
||||
|
||||
visit(tree, 'link', (node: Link) => {
|
||||
const original = node.url;
|
||||
|
||||
// Skip anchors, mailto:, http(s):, protocol-like, or already-rooted paths
|
||||
if (checkIsExternalURL(original) || checkIsAnchor(original) || original.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve against the current page’s directory and strip any leading “/”
|
||||
const pathInSite = path.posix
|
||||
.normalize(path.posix.join(basePath, currentDir, original))
|
||||
.replace(/^\/+/, '');
|
||||
|
||||
node.url = linker.toPathInSite(pathInSite);
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
@@ -10,23 +10,23 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
|
||||
const isRoot = checkIsRootSiteContext(context);
|
||||
const isIndexable = await isSiteIndexable(context);
|
||||
|
||||
const sitemapPath = linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml');
|
||||
const sitemapUrl = linker.toAbsoluteURL(sitemapPath);
|
||||
|
||||
const lines = isIndexable
|
||||
? [
|
||||
'User-agent: *',
|
||||
// Disallow other dynamic routes / search queries
|
||||
'Disallow: /*?',
|
||||
// Allow image resizing and icon generation routes for favicons and search results
|
||||
// Disallow only internal search
|
||||
'Disallow: /*?*q=*',
|
||||
'Disallow: /*?*ask=*',
|
||||
// Allow dynamic assets (may include ?)
|
||||
'Allow: /~gitbook/image?*',
|
||||
'Allow: /~gitbook/icon?*',
|
||||
'Allow: /favicon.ico',
|
||||
'Allow: /',
|
||||
`Sitemap: ${linker.toAbsoluteURL(linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml'))}`,
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
]
|
||||
: ['User-agent: *', 'Disallow: /'];
|
||||
|
||||
const robotsTxt = lines.join('\n');
|
||||
return new Response(robotsTxt, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ const config: Config = {
|
||||
],
|
||||
var: ['var(--font-family)'],
|
||||
},
|
||||
fontSize: {
|
||||
xxs: ['0.625rem', { lineHeight: '0.75rem' }],
|
||||
},
|
||||
colors: {
|
||||
// Dynamic colors matching the customization settings
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import type { OpenAPIContext, OpenAPIUniversalContext } from './context';
|
||||
import { json2xml } from './json2xml';
|
||||
@@ -13,7 +15,7 @@ export function OpenAPIExample(props: {
|
||||
syntax: string;
|
||||
}) {
|
||||
const { example, context, syntax } = props;
|
||||
const code = stringifyExample({ example, xml: syntax === 'xml' });
|
||||
const code = stringifyExample({ example, syntax });
|
||||
|
||||
if (code === null) {
|
||||
return <OpenAPIEmptyExample context={context} />;
|
||||
@@ -22,8 +24,10 @@ export function OpenAPIExample(props: {
|
||||
return context.renderCodeBlock({ code, syntax });
|
||||
}
|
||||
|
||||
function stringifyExample(args: { example: OpenAPIV3.ExampleObject; xml: boolean }): string | null {
|
||||
const { example, xml } = args;
|
||||
function stringifyExample(args: { example: OpenAPIV3.ExampleObject; syntax: string }):
|
||||
| string
|
||||
| null {
|
||||
const { example, syntax } = args;
|
||||
|
||||
if (!example.value) {
|
||||
return null;
|
||||
@@ -33,10 +37,14 @@ function stringifyExample(args: { example: OpenAPIV3.ExampleObject; xml: boolean
|
||||
return example.value;
|
||||
}
|
||||
|
||||
if (xml) {
|
||||
if (syntax === 'xml') {
|
||||
return json2xml(example.value);
|
||||
}
|
||||
|
||||
if (syntax === 'yaml') {
|
||||
return yaml.dump(example.value).replace(/'/g, '').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
return stringifyOpenAPI(example.value, null, 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ function getSyntaxFromMediaType(mediaType: string): string {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (mediaType.includes('yaml')) {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
if (mediaType === 'application/xml') {
|
||||
return 'xml';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user