mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a | |||
| 2a3bb0eb22 | |||
| 6816f0fa5a | |||
| 17386776ec | |||
| 9ee9082614 | |||
| 334cfdd522 | |||
| 8c961a5dbd | |||
| 4708956e77 | |||
| 813dd03dbb | |||
| 0522dbc61e | |||
| acb9f53156 | |||
| 6016846156 | |||
| cf1fae5837 | |||
| 7212345466 | |||
| e38caf78ff | |||
| 0a8dd67541 | |||
| ed684c19a9 | |||
| 52ab3681e3 | |||
| 9cc5a787e9 | |||
| 0003030bf5 | |||
| 9ad8f45d38 | |||
| a3855257c8 | |||
| 9169c2f053 | |||
| abe028bd38 | |||
| 12c2451f4f | |||
| 8daede5427 | |||
| 4c89aa60eb | |||
| d0a3f64ae4 | |||
| 433d66482d | |||
| bd553bcf9e | |||
| f5894bcf74 | |||
| 1b59e7ce36 |
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"packages/fonts": {
|
"packages/fonts": {
|
||||||
"name": "@gitbook/fonts",
|
"name": "@gitbook/fonts",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"packages/gitbook": {
|
"packages/gitbook": {
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.12.0",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "1.2.1",
|
"@opennextjs/cloudflare": "^1.4.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
"ai": "^4.2.2",
|
"ai": "^4.2.2",
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
@@ -96,9 +97,7 @@
|
|||||||
"object-identity": "^0.1.2",
|
"object-identity": "^0.1.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"parse-cache-control": "^1.0.1",
|
"quick-lru": "^7.0.1",
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -147,7 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/icons": {
|
"packages/icons": {
|
||||||
"name": "@gitbook/icons",
|
"name": "@gitbook/icons",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||||
},
|
},
|
||||||
@@ -167,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/openapi-parser": {
|
"packages/openapi-parser": {
|
||||||
"name": "@gitbook/openapi-parser",
|
"name": "@gitbook/openapi-parser",
|
||||||
"version": "2.1.4",
|
"version": "2.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/openapi-parser": "^0.18.0",
|
"@scalar/openapi-parser": "^0.18.0",
|
||||||
"@scalar/openapi-types": "^0.1.9",
|
"@scalar/openapi-types": "^0.1.9",
|
||||||
@@ -182,7 +181,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-contentkit": {
|
"packages/react-contentkit": {
|
||||||
"name": "@gitbook/react-contentkit",
|
"name": "@gitbook/react-contentkit",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
@@ -214,7 +213,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-openapi": {
|
"packages/react-openapi": {
|
||||||
"name": "@gitbook/react-openapi",
|
"name": "@gitbook/react-openapi",
|
||||||
"version": "1.3.0",
|
"version": "1.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
"@scalar/api-client-react": "^1.3.16",
|
"@scalar/api-client-react": "^1.3.16",
|
||||||
@@ -247,7 +246,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.128.0",
|
"@gitbook/api": "^0.129.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||||
@@ -610,7 +609,7 @@
|
|||||||
|
|
||||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
"@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.128.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-VO98hRGfUcFdwMplvFW49jz72/ew2waE7RCu+URKAY2AyPHAduv2zgluF5gF1VcDyuJM9PKFtsdsMtpUjI5sYg=="],
|
"@gitbook/api": ["@gitbook/api@0.129.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Uh+k/BiDgdXj5a8BIlvwfEXdZNNlQMFDlGKmivUYH1gvFySqrTO6PjR/HtUOZEdNauAeWZmGoB8CLuwrVZS+YA=="],
|
||||||
|
|
||||||
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
||||||
|
|
||||||
@@ -788,9 +787,9 @@
|
|||||||
|
|
||||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||||
|
|
||||||
"@opennextjs/aws": ["@opennextjs/aws@3.6.5", "", { "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-wni+CWlRCyWfhNfekQBBPPkrDDnaGdZLN9hMybKI0wKOKTO+zhPOqR65Eh3V0pzWAi84Sureb5mdMuLwCxAAcw=="],
|
"@opennextjs/aws": ["@opennextjs/aws@3.7.0", "", { "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-JXUZice+CedEQW20hnBVqzBEj+jfr4Oe2LVYSE4RNKdfHVIeYG+WJAop14TxRJ+NugKWGcJx6opf944l+ZG7XQ=="],
|
||||||
|
|
||||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.2.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-cOco+nHwlo/PLB1bThF8IIvaS8PgAT9MEI5ZttFO/qt6spgvr2lUaPkpjgSIQmI3sBIEG2cLUykvQ2nbbZEcVw=="],
|
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.4.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.0", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-nEtxmSo6/y83Qw2TSU8rBpljaGl3NUl5GenD/c71h6YqgbP4QURSxMh7ySe7KK+vtQbilD7WePXovqL4Y6D6dw=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -1272,6 +1271,8 @@
|
|||||||
|
|
||||||
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.5", "", {}, "sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg=="],
|
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.5", "", {}, "sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg=="],
|
||||||
|
|
||||||
|
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
||||||
@@ -2412,8 +2413,6 @@
|
|||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
|
|
||||||
|
|
||||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||||
|
|
||||||
"parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="],
|
"parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="],
|
||||||
@@ -2504,7 +2503,7 @@
|
|||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
|
"quick-lru": ["quick-lru@7.0.1", "", {}, "sha512-kLjThirJMkWKutUKbZ8ViqFc09tDQhlbQo2MNuVeLWbRauqYP96Sm6nzlQ24F0HFjUNZ4i9+AgldJ9H6DZXi7g=="],
|
||||||
|
|
||||||
"radix-vue": ["radix-vue@1.9.7", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-1xleWzWNFPfAMmb81gu/4/MV8dXMvc7j2EIjutBpBcKwxdJfeIcQg4k9De18L2rL1/GZg5wA9KykeKTM4MjWow=="],
|
"radix-vue": ["radix-vue@1.9.7", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-1xleWzWNFPfAMmb81gu/4/MV8dXMvc7j2EIjutBpBcKwxdJfeIcQg4k9De18L2rL1/GZg5wA9KykeKTM4MjWow=="],
|
||||||
|
|
||||||
@@ -4048,6 +4047,8 @@
|
|||||||
|
|
||||||
"cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
"cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
||||||
|
|
||||||
|
"camelcase-keys/quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
|
||||||
|
|
||||||
"codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
|
"codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
|
||||||
|
|
||||||
"codemirror/@codemirror/commands": ["@codemirror/commands@6.7.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="],
|
"codemirror/@codemirror/commands": ["@codemirror/commands@6.7.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="],
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.128.0"
|
"@gitbook/api": "^0.129.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function getCacheTag(
|
|||||||
| {
|
| {
|
||||||
tag: 'translation';
|
tag: 'translation';
|
||||||
organization: string;
|
organization: string;
|
||||||
translationSettings: string;
|
translation: string;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
@@ -115,7 +115,7 @@ export function getCacheTag(
|
|||||||
case 'openapi':
|
case 'openapi':
|
||||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
case 'translation':
|
case 'translation':
|
||||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
return `organization:${spec.organization}:translation:${spec.translation}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
|
|||||||
) {
|
) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
if (!('dependencies' in source)) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||||
// when the dependencies are updated.
|
// when the dependencies are updated.
|
||||||
const dependencies = Object.values(source.dependencies ?? {});
|
const dependencies = Object.values(source.dependencies ?? {});
|
||||||
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'translation-language':
|
case 'translation':
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'translation',
|
tag: 'translation',
|
||||||
organization: inContext.organizationId,
|
organization: inContext.organizationId,
|
||||||
translationSettings: dependency.ref.translationSettings,
|
translation: dependency.ref.translation,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.14.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 7c951ef: 'Support new colors in text formatting
|
||||||
|
- 39c4f76: Add fullwidth page option
|
||||||
|
- eb1bd3a: AI response feedback buttons
|
||||||
|
- 2ba7e54: Fix headings styles in hint block
|
||||||
|
- 250c194: Add nosnippet to announcement banner.
|
||||||
|
- 4aeb81b: Fix search input styling & text cutoff
|
||||||
|
- 68f0dbc: Add confirmation modal to contentkit button
|
||||||
|
- 611e286: Remove padding on first Heading in Columns
|
||||||
|
- b0c534f: Tweaks to AI Chat and AI Actions Dropdown
|
||||||
|
- Updated dependencies [b5ad0ce]
|
||||||
|
- @gitbook/openapi-parser@2.2.2
|
||||||
|
- @gitbook/react-openapi@1.3.4
|
||||||
|
|
||||||
|
## 0.14.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 0003030: Implement AI actions dropdown
|
||||||
|
- acb9f53: New search layout
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 334cfdd: Fix responsive class for SearchInput button
|
||||||
|
- 6816f0f: add browserlists and fix old browser css-masks
|
||||||
|
- 9ee9082: Refactor icon loading state in AIAction components
|
||||||
|
- 52ab368: Reposition AI Actions dropdown
|
||||||
|
- 7212345: Fix AI Actions dropdown and LLM integration
|
||||||
|
- 8daede5: Ensure all content links are resolved relatively to preview.
|
||||||
|
- ed684c1: Move AI Actions markdown fetching to client-side
|
||||||
|
- 9cc5a78: Generalise keyboard shortcuts, add Cmd+J to AI Chat
|
||||||
|
- Updated dependencies [6816f0f]
|
||||||
|
- Updated dependencies [1738677]
|
||||||
|
- @gitbook/icons@0.2.1
|
||||||
|
- @gitbook/openapi-parser@2.2.1
|
||||||
|
- @gitbook/react-contentkit@0.7.2
|
||||||
|
- @gitbook/react-openapi@1.3.3
|
||||||
|
|
||||||
|
## 0.13.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f5894bc: Fix incorrect revision ID in Markdown fetch logic
|
||||||
|
- 1b59e7c: Styling improvements to AI Chat Followup questions
|
||||||
|
- Updated dependencies [bd553bc]
|
||||||
|
- @gitbook/openapi-parser@2.2.0
|
||||||
|
- @gitbook/react-openapi@1.3.2
|
||||||
|
|
||||||
## 0.13.0
|
## 0.13.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -415,6 +415,28 @@ const testCases: TestsCase[] = [
|
|||||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'With sections',
|
||||||
|
url: async () => {
|
||||||
|
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set('token', data.apiToken);
|
||||||
|
|
||||||
|
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||||
|
},
|
||||||
|
screenshot: false,
|
||||||
|
run: async (page) => {
|
||||||
|
const sectionTabs = page.getByLabel('Sections');
|
||||||
|
await expect(sectionTabs).toBeVisible();
|
||||||
|
|
||||||
|
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||||
|
for (const link of await sectionTabLinks.all()) {
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -910,6 +932,22 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Page actions',
|
||||||
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Without page actions',
|
||||||
|
url: getCustomizationURL({
|
||||||
|
pageActions: {
|
||||||
|
markdown: false,
|
||||||
|
externalAI: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
run: waitForCookiesDialog,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Ads',
|
name: 'Ads',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
|||||||
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
feedback: {
|
feedback: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
|
|
||||||
aiSearch: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
ai: {
|
ai: {
|
||||||
mode: CustomizationAIMode.None,
|
mode: CustomizationAIMode.None,
|
||||||
},
|
},
|
||||||
@@ -337,6 +333,10 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
pagination: {
|
pagination: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
pageActions: {
|
||||||
|
externalAI: true,
|
||||||
|
markdown: true,
|
||||||
|
},
|
||||||
trademark: {
|
trademark: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const nextConfig = {
|
|||||||
dynamic: 3600, // 1 hour
|
dynamic: 3600, // 1 hour
|
||||||
static: 3600, // 1 hour
|
static: 3600, // 1 hour
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Since content is fully static, we don't want to fetch on hover again
|
||||||
|
optimisticClientCache: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev"
|
"STAGE": "dev",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
@@ -84,6 +85,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staging": {
|
"staging": {
|
||||||
|
"vars": {
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
@@ -125,7 +129,8 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||||
// This means that we'll have up to 100 durable objects instance running at the same time
|
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||||
"MAX_REVALIDATE_CONCURRENCY": "100"
|
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "dev",
|
"STAGE": "dev",
|
||||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
@@ -85,7 +86,8 @@
|
|||||||
"staging": {
|
"staging": {
|
||||||
"vars": {
|
"vars": {
|
||||||
"STAGE": "staging",
|
"STAGE": "staging",
|
||||||
"WORKER_VERSION_ID": "TO_REPLACE"
|
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
@@ -153,7 +155,8 @@
|
|||||||
// TODO: remove this once the issue is fixed
|
// TODO: remove this once the issue is fixed
|
||||||
"DEBUG_CLOUDFLARE": "true",
|
"DEBUG_CLOUDFLARE": "true",
|
||||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||||
"STAGE": "production"
|
"STAGE": "production",
|
||||||
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { trace } from '@/lib/tracing';
|
|
||||||
import type {
|
import type {
|
||||||
CacheEntryType,
|
CacheEntryType,
|
||||||
CacheValue,
|
CacheValue,
|
||||||
@@ -34,13 +32,7 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
cacheType?: CacheType
|
cacheType?: CacheType
|
||||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||||
const cacheKey = this.getR2Key(key, cacheType);
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheGet',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
|
||||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
const localCache = await this.getCacheInstance();
|
const localCache = await this.getCacheInstance();
|
||||||
if (!r2) throw new Error('No R2 bucket');
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
@@ -48,7 +40,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
// Check local cache first if available
|
// Check local cache first if available
|
||||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||||
if (localCacheEntry) {
|
if (localCacheEntry) {
|
||||||
span.setAttribute('cacheHit', 'local');
|
|
||||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||||
CacheValue<CacheType>
|
CacheValue<CacheType>
|
||||||
>;
|
>;
|
||||||
@@ -58,7 +49,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
const r2Object = await r2.get(cacheKey);
|
const r2Object = await r2.get(cacheKey);
|
||||||
if (!r2Object) return null;
|
if (!r2Object) return null;
|
||||||
|
|
||||||
span.setAttribute('cacheHit', 'r2');
|
|
||||||
return this.returnNullOn404({
|
return this.returnNullOn404({
|
||||||
value: await r2Object.json(),
|
value: await r2Object.json(),
|
||||||
lastModified: r2Object.uploaded.getTime(),
|
lastModified: r2Object.uploaded.getTime(),
|
||||||
@@ -68,8 +58,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: This is a workaround to handle 404 responses in the cache.
|
//TODO: This is a workaround to handle 404 responses in the cache.
|
||||||
// It should be handled by OpenNext cache interception directly. This should be removed once OpenNext cache interception is fixed.
|
// It should be handled by OpenNext cache interception directly. This should be removed once OpenNext cache interception is fixed.
|
||||||
@@ -89,13 +77,7 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
cacheType?: CacheType
|
cacheType?: CacheType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cacheKey = this.getR2Key(key, cacheType);
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheSet',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
|
||||||
const localCache = await this.getCacheInstance();
|
const localCache = await this.getCacheInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -133,17 +115,10 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
console.error('Failed to set to cache', e);
|
console.error('Failed to set to cache', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
const cacheKey = this.getR2Key(key);
|
const cacheKey = this.getR2Key(key);
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'openNextIncrementalCacheDelete',
|
|
||||||
name: cacheKey,
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
const localCache = await this.getCacheInstance();
|
const localCache = await this.getCacheInstance();
|
||||||
if (!r2) throw new Error('No R2 bucket');
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
@@ -157,8 +132,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
console.error('Failed to delete from cache', e);
|
console.error('Failed to delete from cache', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeToR2(key: string, value: string): Promise<void> {
|
async writeToR2(key: string, value: string): Promise<void> {
|
||||||
const env = getCloudflareContext().env as {
|
const env = getCloudflareContext().env as {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { trace } from '@/lib/tracing';
|
|
||||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||||
@@ -6,9 +5,7 @@ import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
|||||||
export default {
|
export default {
|
||||||
name: 'GitbookISRQueue',
|
name: 'GitbookISRQueue',
|
||||||
send: async (msg) => {
|
send: async (msg) => {
|
||||||
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
|
|
||||||
const { ctx } = getCloudflareContext();
|
const { ctx } = getCloudflareContext();
|
||||||
ctx.waitUntil(doQueue.send(msg));
|
ctx.waitUntil(doQueue.send(msg));
|
||||||
});
|
|
||||||
},
|
},
|
||||||
} satisfies Queue;
|
} satisfies Queue;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { getLogger } from '@/lib/logger';
|
||||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GitbookISRQueue',
|
name: 'GitbookISRQueue',
|
||||||
send: async (msg) => {
|
send: async (msg) => {
|
||||||
|
const logger = getLogger().subLogger('GitbookISRQueue');
|
||||||
// We should never reach this point in the server. If that's the case, we should log it.
|
// We should never reach this point in the server. If that's the case, we should log it.
|
||||||
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
|
logger.warn('send called on server side, this should not happen.', msg);
|
||||||
},
|
},
|
||||||
} satisfies Queue;
|
} satisfies Queue;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { trace } from '@/lib/tracing';
|
import { getLogger } from '@/lib/logger';
|
||||||
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||||
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||||
@@ -23,59 +23,27 @@ export default {
|
|||||||
getLastRevalidated: async (tags: string[]) => {
|
getLastRevalidated: async (tags: string[]) => {
|
||||||
const tagsToCheck = tags.filter(softTagFilter);
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
if (tagsToCheck.length === 0) {
|
if (tagsToCheck.length === 0) {
|
||||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
|
||||||
console.warn(
|
|
||||||
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
|
|
||||||
tags
|
|
||||||
);
|
|
||||||
return 0; // If no tags to check, return 0
|
return 0; // If no tags to check, return 0
|
||||||
}
|
}
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'gitbookTagCacheGetLastRevalidated',
|
|
||||||
name: tagsToCheck.join(', '),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||||
const tagsToCheck = tags.filter(softTagFilter);
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
if (tagsToCheck.length === 0) {
|
if (tagsToCheck.length === 0) {
|
||||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
|
||||||
console.warn(
|
|
||||||
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
|
|
||||||
tags
|
|
||||||
);
|
|
||||||
return false; // If no tags to check, return false
|
return false; // If no tags to check, return false
|
||||||
}
|
}
|
||||||
return trace(
|
|
||||||
{
|
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||||
operation: 'gitbookTagCacheHasBeenRevalidated',
|
|
||||||
name: tagsToCheck.join(', '),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
writeTags: async (tags: string[]) => {
|
writeTags: async (tags: string[]) => {
|
||||||
return trace(
|
|
||||||
{
|
|
||||||
operation: 'gitbookTagCacheWriteTags',
|
|
||||||
name: tags.join(', '),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const tagsToWrite = tags.filter(softTagFilter);
|
const tagsToWrite = tags.filter(softTagFilter);
|
||||||
if (tagsToWrite.length === 0) {
|
if (tagsToWrite.length === 0) {
|
||||||
console.warn('writeTags - No valid tags to write');
|
const logger = getLogger().subLogger('gitbookTagCache');
|
||||||
|
logger.warn('writeTags - No valid tags to write');
|
||||||
return; // If no tags to write, exit early
|
return; // If no tags to write, exit early
|
||||||
}
|
}
|
||||||
// Write only the filtered tags
|
// Write only the filtered tags
|
||||||
await originalTagCache.writeTags(tagsToWrite);
|
await originalTagCache.writeTags(tagsToWrite);
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
} satisfies NextModeTagCache;
|
} satisfies NextModeTagCache;
|
||||||
|
|||||||
@@ -1,75 +1,74 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.13.0",
|
"version": "0.14.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "1.2.1",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"assert-never": "^1.2.1",
|
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"next": "^15.3.2",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"rison": "^0.1.1",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"warn-once": "^0.1.1",
|
|
||||||
"object-identity": "^0.1.2",
|
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
|
"@gitbook/icons": "workspace:*",
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
|
"@opennextjs/cloudflare": "^1.4.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"ai": "^4.2.2",
|
"ai": "^4.2.2",
|
||||||
|
"assert-never": "^1.2.1",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"direction": "^2.0.1",
|
||||||
"event-iterator": "^2.0.0",
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
"framer-motion": "^10.16.14",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
|
||||||
"mdast-util-from-markdown": "^2.0.2",
|
"mdast-util-from-markdown": "^2.0.2",
|
||||||
"mdast-util-frontmatter": "^2.0.1",
|
"mdast-util-frontmatter": "^2.0.1",
|
||||||
"mdast-util-gfm": "^3.1.0",
|
"mdast-util-gfm": "^3.1.0",
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"micromark-extension-frontmatter": "^2.0.0",
|
|
||||||
"unist-util-remove": "^4.0.0",
|
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
|
"micromark-extension-frontmatter": "^2.0.0",
|
||||||
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
|
"next": "^15.3.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
|
"object-identity": "^0.1.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"parse-cache-control": "^1.0.1",
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.1",
|
||||||
|
"rison": "^0.1.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"shiki": "^3.2.0",
|
"shiki": "^3.2.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwind-shades": "^1.1.2",
|
"tailwind-shades": "^1.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-remove": "^4.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"warn-once": "^0.1.1",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3",
|
||||||
"image-size": "^2.0.2",
|
"quick-lru": "^7.0.1"
|
||||||
"direction": "^2.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^5.0.5",
|
"@argos-ci/playwright": "^5.0.5",
|
||||||
@@ -112,5 +111,8 @@
|
|||||||
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
||||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
}
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getLogger } from '@/lib/logger';
|
||||||
import { withVerifySignature } from '@/lib/routes';
|
import { withVerifySignature } from '@/lib/routes';
|
||||||
import { revalidateTag } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface JsonBody {
|
|||||||
* The body should be a JSON with { tags: string[] }
|
* The body should be a JSON with { tags: string[] }
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const logger = getLogger().subLogger('revalidate');
|
||||||
return withVerifySignature<JsonBody>(req, async (body) => {
|
return withVerifySignature<JsonBody>(req, async (body) => {
|
||||||
if (!body.tags || !Array.isArray(body.tags)) {
|
if (!body.tags || !Array.isArray(body.tags)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -23,8 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.tags.forEach((tag) => {
|
body.tags.forEach((tag) => {
|
||||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
logger.log(`Revalidating tag: ${tag}`);
|
||||||
console.log(`Revalidating tag: ${tag}`);
|
|
||||||
revalidateTag(tag);
|
revalidateTag(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type { AIMessage } from '@gitbook/api';
|
import type { AIMessage } from '@gitbook/api';
|
||||||
import { DocumentView } from '../../DocumentView';
|
import { DocumentView } from '../../DocumentView';
|
||||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||||
@@ -19,7 +20,13 @@ export function AIMessageView(
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{message.steps.map((step, index) => {
|
{message.steps.map((step, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex animate-[fadeIn_500ms_both] flex-col gap-2">
|
<div
|
||||||
|
key={index}
|
||||||
|
className={tcls(
|
||||||
|
'flex animate-fadeIn-slow flex-col gap-2',
|
||||||
|
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DocumentView
|
<DocumentView
|
||||||
document={step.content}
|
document={step.content}
|
||||||
context={{
|
context={{
|
||||||
@@ -28,8 +35,9 @@ export function AIMessageView(
|
|||||||
wrapBlocksInSuspense: false,
|
wrapBlocksInSuspense: false,
|
||||||
shouldRenderLinkPreviews: true,
|
shouldRenderLinkPreviews: true,
|
||||||
}}
|
}}
|
||||||
style={['space-y-4']}
|
style="mt-2 space-y-4 empty:hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getSpaceLanguage } from '@/intl/server';
|
|||||||
import { t } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type {
|
import type {
|
||||||
AIToolCall,
|
AIToolCall,
|
||||||
AIToolCallGetPageContent,
|
AIToolCallGetPageContent,
|
||||||
@@ -37,7 +38,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
|
|||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 text-sm text-tint-subtle">
|
<div className="flex origin-left animate-scaleIn-slow items-start gap-2 text-sm text-tint-subtle">
|
||||||
<Icon
|
<Icon
|
||||||
icon={getIconForToolCall(toolCall)}
|
icon={getIconForToolCall(toolCall)}
|
||||||
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
||||||
@@ -144,17 +145,25 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasResults = toolCall.results.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="-ml-5 group flex w-full flex-col gap-2">
|
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
|
||||||
<summary className="-mx-2 -mt-2 flex cursor-pointer list-none items-center gap-2 rounded-corners:rounded-md py-2 pr-4 pl-7 transition-colors marker:hidden hover:bg-primary-hover">
|
<summary
|
||||||
<div className="flex flex-col">
|
className={tcls(
|
||||||
|
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
|
||||||
|
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col leading-snug">
|
||||||
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
||||||
<p className="text-tint-subtle text-xs">
|
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||||
{toolCall.results.length
|
{hasResults
|
||||||
? t(language, 'search_results_count', toolCall.results.length)
|
? t(language, 'search_results_count', toolCall.results.length)
|
||||||
: t(language, 'search_no_results')}
|
: t(language, 'search_no_results')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasResults ? (
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
||||||
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
||||||
@@ -163,13 +172,15 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
|
{hasResults ? (
|
||||||
|
<div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
|
||||||
<ol className="space-y-1">
|
<ol className="space-y-1">
|
||||||
{searchResultsWithHrefs.map((result, index) => (
|
{searchResultsWithHrefs.map((result, index) => (
|
||||||
<li
|
<li
|
||||||
key={`${result.pageId}-${index}`}
|
key={`${result.pageId}-${index}`}
|
||||||
className="animate-fadeIn"
|
className="animate-fadeIn-slow"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${index * 25}ms`,
|
animationDelay: `${index * 25}ms`,
|
||||||
}}
|
}}
|
||||||
@@ -207,6 +218,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAIMessageContextRef } from './useAIMessageContext';
|
|||||||
export type AIChatMessage = {
|
export type AIChatMessage = {
|
||||||
role: AIMessageRole;
|
role: AIMessageRole;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
|
query?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AIChatState = {
|
export type AIChatState = {
|
||||||
@@ -24,6 +25,11 @@ export type AIChatState = {
|
|||||||
*/
|
*/
|
||||||
responseId: string | null;
|
responseId: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest query sent to the AI.
|
||||||
|
*/
|
||||||
|
query: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages in the session.
|
* Messages in the session.
|
||||||
*/
|
*/
|
||||||
@@ -73,6 +79,7 @@ const globalState = zustand.create<{
|
|||||||
opened: false,
|
opened: false,
|
||||||
responseId: null,
|
responseId: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -106,6 +113,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
loading: false,
|
loading: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
responseId: null,
|
responseId: null,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -128,6 +136,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
content: null,
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
query: input.message,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAIChatController } from '@/components/AI/useAIChat';
|
||||||
|
import { useAIChatState } from '@/components/AI/useAIChat';
|
||||||
|
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||||
|
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||||
|
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||||
|
import { getAIChatName } from '@/components/AIChat';
|
||||||
|
import { AIChatIcon } from '@/components/AIChat';
|
||||||
|
import { Button } from '@/components/primitives/Button';
|
||||||
|
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
|
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import QuickLRU from 'quick-lru';
|
||||||
|
import type React from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type AIActionType = 'button' | 'dropdown-menu-item';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens our AI Docs Assistant.
|
||||||
|
*/
|
||||||
|
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
|
||||||
|
const { type, trademark } = props;
|
||||||
|
const chatController = useAIChatController();
|
||||||
|
const chat = useAIChatState();
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIActionWrapper
|
||||||
|
type={type}
|
||||||
|
icon={
|
||||||
|
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
|
||||||
|
}
|
||||||
|
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
|
||||||
|
shortLabel={tString(language, 'ask')}
|
||||||
|
description={tString(language, 'ai_chat_ask_about_page', getAIChatName(trademark))}
|
||||||
|
disabled={chat.loading}
|
||||||
|
onClick={() => {
|
||||||
|
// Open the chat if it's not already open
|
||||||
|
if (!chat.opened) {
|
||||||
|
chatController.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the "What is this page about?" message
|
||||||
|
chatController.postMessage({
|
||||||
|
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopiedStore = {
|
||||||
|
copied: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// We need to store everything in a store to share the state between every instance of the component.
|
||||||
|
const useCopiedStore = create<
|
||||||
|
CopiedStore & {
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||||
|
}
|
||||||
|
>((set) => {
|
||||||
|
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
copied: false,
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading: boolean) => set({ loading }),
|
||||||
|
copy: async (data, opts) => {
|
||||||
|
const { onSuccess } = opts || {};
|
||||||
|
|
||||||
|
if (timeoutRef) {
|
||||||
|
clearTimeout(timeoutRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(data);
|
||||||
|
|
||||||
|
set({ copied: true });
|
||||||
|
|
||||||
|
timeoutRef = setTimeout(() => {
|
||||||
|
set({ copied: false });
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
// Reset the timeout ref to avoid multiple timeouts
|
||||||
|
timeoutRef = null;
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for the markdown versbion of the page.
|
||||||
|
*/
|
||||||
|
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the markdown version of the page to the clipboard.
|
||||||
|
*/
|
||||||
|
export function CopyMarkdown(props: {
|
||||||
|
markdownPageUrl: string;
|
||||||
|
type: AIActionType;
|
||||||
|
isDefaultAction?: boolean;
|
||||||
|
}) {
|
||||||
|
const { markdownPageUrl, type, isDefaultAction } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
const closeDropdown = useDropdownMenuClose();
|
||||||
|
|
||||||
|
const { copied, loading, setLoading, copy } = useCopiedStore();
|
||||||
|
|
||||||
|
// Fetch the markdown from the page
|
||||||
|
const fetchMarkdown = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await fetch(markdownPageUrl).then((res) => res.text());
|
||||||
|
markdownCache.set(markdownPageUrl, result);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async (e: React.MouseEvent) => {
|
||||||
|
// Prevent default behavior for non-default actions to avoid closing the dropdown.
|
||||||
|
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
|
||||||
|
if (!isDefaultAction) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), {
|
||||||
|
onSuccess: () => {
|
||||||
|
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
|
||||||
|
if (type === 'dropdown-menu-item' && !isDefaultAction) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIActionWrapper
|
||||||
|
type={type}
|
||||||
|
icon={copied ? 'check' : 'copy'}
|
||||||
|
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
|
||||||
|
shortLabel={copied ? tString(language, 'code_copied') : tString(language, 'code_copy')}
|
||||||
|
description={tString(language, 'copy_page_markdown')}
|
||||||
|
onClick={onClick}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects to the markdown version of the page.
|
||||||
|
*/
|
||||||
|
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
|
||||||
|
const { markdownPageUrl, type } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIActionWrapper
|
||||||
|
type={type}
|
||||||
|
icon={<MarkdownIcon className="size-4 fill-current" />}
|
||||||
|
label={tString(language, 'view_page_markdown')}
|
||||||
|
description={tString(language, 'view_page_plaintext')}
|
||||||
|
href={markdownPageUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
|
||||||
|
*/
|
||||||
|
export function OpenInLLM(props: {
|
||||||
|
provider: 'chatgpt' | 'claude';
|
||||||
|
url: string;
|
||||||
|
type: AIActionType;
|
||||||
|
}) {
|
||||||
|
const { provider, url, type } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIActionWrapper
|
||||||
|
type={type}
|
||||||
|
icon={
|
||||||
|
provider === 'chatgpt' ? (
|
||||||
|
<ChatGPTIcon className="size-3.5 fill-current" />
|
||||||
|
) : (
|
||||||
|
<ClaudeIcon className="size-3.5 fill-current" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={tString(language, 'open_in', providerLabel)}
|
||||||
|
shortLabel={providerLabel}
|
||||||
|
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
|
||||||
|
href={getLLMURL(provider, url, language)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an action in a button (for the default action) or dropdown menu item.
|
||||||
|
*/
|
||||||
|
function AIActionWrapper(props: {
|
||||||
|
type: AIActionType;
|
||||||
|
icon: IconName | React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* The label to display in the button. If not provided, the `label` will be used.
|
||||||
|
*/
|
||||||
|
shortLabel?: string;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
description?: string;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
const { type, icon, label, shortLabel, onClick, href, description, disabled, loading } = props;
|
||||||
|
|
||||||
|
if (type === 'button') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
loading ? <Icon icon="spinner-third" className="size-4 animate-spin" /> : icon
|
||||||
|
}
|
||||||
|
size="xsmall"
|
||||||
|
variant="secondary"
|
||||||
|
label={shortLabel || label}
|
||||||
|
className="bg-tint-base text-sm"
|
||||||
|
onClick={onClick}
|
||||||
|
href={href}
|
||||||
|
target={href ? '_blank' : undefined}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex items-stretch gap-2.5 p-2"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
<div className="flex size-5 items-center justify-center text-tint">
|
||||||
|
{loading ? (
|
||||||
|
<Icon icon="spinner-third" className="size-4 animate-spin" />
|
||||||
|
) : icon ? (
|
||||||
|
typeof icon === 'string' ? (
|
||||||
|
<Icon
|
||||||
|
icon={icon as IconName}
|
||||||
|
iconStyle={IconStyle.Regular}
|
||||||
|
className="size-4 fill-transparent stroke-current"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-0.5">
|
||||||
|
<span className="flex items-center gap-2 text-tint-strong">
|
||||||
|
<span className="truncate font-medium text-sm">{label}</span>
|
||||||
|
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
|
||||||
|
</span>
|
||||||
|
{description && <span className="truncate text-tint text-xs">{description}</span>}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the URL to open the page in a LLM with a pre-filled prompt.
|
||||||
|
*/
|
||||||
|
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) {
|
||||||
|
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url));
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'chatgpt':
|
||||||
|
return `https://chat.openai.com/?q=${prompt}`;
|
||||||
|
case 'claude':
|
||||||
|
return `https://claude.ai/new?q=${prompt}`;
|
||||||
|
default:
|
||||||
|
assertNever(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CopyMarkdown,
|
||||||
|
OpenDocsAssistant,
|
||||||
|
OpenInLLM,
|
||||||
|
ViewAsMarkdown,
|
||||||
|
} from '@/components/AIActions/AIActions';
|
||||||
|
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||||
|
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||||
|
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||||
|
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
interface AIActionsDropdownProps {
|
||||||
|
markdownPageUrl: string;
|
||||||
|
withAIChat?: boolean;
|
||||||
|
trademark: boolean;
|
||||||
|
actions: SiteCustomizationSettings['pageActions'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||||
|
*/
|
||||||
|
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup ref={ref}>
|
||||||
|
<DefaultAction {...props} />
|
||||||
|
{props.actions.markdown || props.actions.externalAI ? (
|
||||||
|
<DropdownMenu
|
||||||
|
align="end"
|
||||||
|
className="!min-w-60 max-w-max"
|
||||||
|
button={
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon="chevron-down"
|
||||||
|
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
iconOnly
|
||||||
|
size="xsmall"
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-tint-base text-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AIActionsDropdownMenuContent {...props} />
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content of the dropdown menu.
|
||||||
|
*/
|
||||||
|
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||||
|
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{withAIChat ? (
|
||||||
|
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{actions.markdown ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="first:hidden" />
|
||||||
|
<CopyMarkdown
|
||||||
|
isDefaultAction={!withAIChat}
|
||||||
|
markdownPageUrl={markdownPageUrl}
|
||||||
|
type="dropdown-menu-item"
|
||||||
|
/>
|
||||||
|
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{actions.externalAI ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="first:hidden" />
|
||||||
|
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
|
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A default action shown as a quick-access button beside the dropdown menu
|
||||||
|
*/
|
||||||
|
function DefaultAction(props: AIActionsDropdownProps) {
|
||||||
|
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||||
|
|
||||||
|
if (withAIChat) {
|
||||||
|
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.markdown) {
|
||||||
|
return (
|
||||||
|
<CopyMarkdown
|
||||||
|
isDefaultAction={!withAIChat}
|
||||||
|
markdownPageUrl={markdownPageUrl}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.externalAI) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
|
||||||
|
<title>ChatGPT</title>
|
||||||
|
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 256 257"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Claude</title>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
|
||||||
|
<title>Markdown</title>
|
||||||
|
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
|
||||||
|
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import {
|
import {
|
||||||
type AIChatController,
|
type AIChatController,
|
||||||
type AIChatState,
|
type AIChatState,
|
||||||
@@ -12,27 +13,48 @@ import {
|
|||||||
import { useNow } from '../hooks';
|
import { useNow } from '../hooks';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||||
import AIChatIcon from './AIChatIcon';
|
import { AIChatIcon } from './AIChatIcon';
|
||||||
import { AIChatInput } from './AIChatInput';
|
import { AIChatInput } from './AIChatInput';
|
||||||
import { AIChatMessages } from './AIChatMessages';
|
import { AIChatMessages } from './AIChatMessages';
|
||||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
|
||||||
|
|
||||||
export function AIChat() {
|
export function AIChat(props: { trademark: boolean }) {
|
||||||
|
const { trademark } = props;
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
|
const chatController = useAIChatController();
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'mod+j',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
chatController.open();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'esc',
|
||||||
|
() => {
|
||||||
|
chatController.close();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
if (!chat.opened) {
|
if (!chat.opened) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AIChatWindow chat={chat} />;
|
return <AIChatWindow trademark={trademark} chatController={chatController} chat={chat} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatWindow(props: { chat: AIChatState }) {
|
export function AIChatWindow(props: {
|
||||||
const { chat } = props;
|
chatController: AIChatController;
|
||||||
|
chat: AIChatState;
|
||||||
|
trademark: boolean;
|
||||||
|
}) {
|
||||||
|
const { chatController, chat, trademark } = props;
|
||||||
|
|
||||||
const [input, setInput] = React.useState('');
|
const [input, setInput] = React.useState('');
|
||||||
const chatController = useAIChatController();
|
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -86,17 +108,28 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
|
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||||
<div className="flex items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
|
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
|
||||||
<AIChatIcon
|
<AIChatIcon
|
||||||
className={`size-5 text-tint ${chat.loading ? 'animate-pulse' : ''}`}
|
className="size-5 text-tint"
|
||||||
state={chat.loading ? 'thinking' : 'default'}
|
trademark={trademark}
|
||||||
|
state={
|
||||||
|
chat.error
|
||||||
|
? 'error'
|
||||||
|
: chat.loading
|
||||||
|
? chat.messages[chat.messages.length - 1].content
|
||||||
|
? 'working'
|
||||||
|
: 'thinking'
|
||||||
|
: chat.messages.length > 0
|
||||||
|
? 'done'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="font-bold">Docs Assistant</div>
|
<div className="font-bold">{getAIChatName(trademark)}</div>
|
||||||
<div
|
<div
|
||||||
className={`text-tint text-xs leading-none transition-all duration-500 ${
|
className={`text-tint text-xs leading-none transition-all duration-500 ${
|
||||||
chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'
|
chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'
|
||||||
@@ -116,7 +149,7 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
|||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
iconOnly
|
iconOnly
|
||||||
icon="ellipsis"
|
icon="ellipsis"
|
||||||
label={tString(language, 'more')}
|
label={tString(language, 'actions')}
|
||||||
className="!px-2"
|
className="!px-2"
|
||||||
variant="blank"
|
variant="blank"
|
||||||
size="default"
|
size="default"
|
||||||
@@ -129,7 +162,10 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
|||||||
}}
|
}}
|
||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
>
|
>
|
||||||
<Icon icon="broom-wide" className="size-3 text-tint-subtle" />
|
<Icon
|
||||||
|
icon="broom-wide"
|
||||||
|
className="size-3 shrink-0 text-tint-subtle"
|
||||||
|
/>
|
||||||
{t(language, 'ai_chat_clear_conversation')}
|
{t(language, 'ai_chat_clear_conversation')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -153,8 +189,12 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
|||||||
>
|
>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
||||||
<div className="flex size-32 animate-[fadeIn_500ms_both] items-center justify-center rounded-full bg-tint-subtle">
|
<div className="flex size-32 animate-fadeIn-slow items-center justify-center rounded-full bg-tint-subtle">
|
||||||
<AIChatIcon className="size-16 animate-[present_500ms_200ms_both]" />
|
<AIChatIcon
|
||||||
|
state="intro"
|
||||||
|
trademark={trademark}
|
||||||
|
className="size-16 animate-[present_500ms_200ms_both]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-[fadeIn_500ms_400ms_both]">
|
<div className="animate-[fadeIn_500ms_400ms_both]">
|
||||||
<h5 className=" text-center font-bold text-lg text-tint-strong">
|
<h5 className=" text-center font-bold text-lg text-tint-strong">
|
||||||
@@ -169,19 +209,19 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AIChatMessages chat={chat} lastUserMessageRef={lastUserMessageRef} />
|
<AIChatMessages
|
||||||
|
chat={chat}
|
||||||
|
chatController={chatController}
|
||||||
|
lastUserMessageRef={lastUserMessageRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
||||||
>
|
>
|
||||||
{/* Display an error banner when something went wrong. */}
|
{/* Display an error banner when something went wrong. */}
|
||||||
{chat.error ? (
|
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||||
<AIChatError chatController={chatController} />
|
|
||||||
) : (
|
|
||||||
<AIChatFollowupSuggestions chat={chat} chatController={chatController} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AIChatInput
|
<AIChatInput
|
||||||
value={input}
|
value={input}
|
||||||
@@ -204,7 +244,7 @@ function AIChatError(props: { chatController: AIChatController }) {
|
|||||||
const { chatController } = props;
|
const { chatController } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-between gap-2 rounded-md bg-danger p-3 text-danger text-sm ring-1 ring-danger">
|
<div className="flex animate-scaleIn flex-wrap justify-between gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md bg-danger px-3 py-2 text-danger text-sm ring-1 ring-danger">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon icon="exclamation-triangle" className="size-3.5" />
|
<Icon icon="exclamation-triangle" className="size-3.5" />
|
||||||
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
|
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
|
||||||
@@ -224,3 +264,11 @@ function AIChatError(props: { chatController: AIChatController }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAIChatName(trademark: boolean) {
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
return trademark
|
||||||
|
? tString(language, 'ai_chat_assistant_name')
|
||||||
|
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import AIChatIcon from './AIChatIcon';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
|
import { getAIChatName } from './AIChat';
|
||||||
|
import { AIChatIcon } from './AIChatIcon';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button to open/close the AI chat.
|
* Button to open/close the AI chat.
|
||||||
*/
|
*/
|
||||||
export function AIChatButton() {
|
export function AIChatButton(props: { trademark: boolean }) {
|
||||||
|
const { trademark } = props;
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
icon={<AIChatIcon />}
|
icon={<AIChatIcon trademark={trademark} />}
|
||||||
iconOnly
|
iconOnly
|
||||||
size="default"
|
size="default"
|
||||||
variant="secondary"
|
variant="header"
|
||||||
className="!px-3 bg-tint-base py-2.5"
|
className={tcls('h-9 px-2.5')}
|
||||||
label={tString(language, 'ai_chat_assistant_name')}
|
label={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{t(language, 'ai_chat_ask', getAIChatName(trademark))}
|
||||||
|
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chat.opened) {
|
if (chat.opened) {
|
||||||
chatController.close();
|
chatController.close();
|
||||||
|
|||||||
@@ -1,64 +1,150 @@
|
|||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: 'thinking' | 'default';
|
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
|
||||||
|
trademark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AIChatIcon = ({
|
export const AIChatIcon = ({
|
||||||
className = 'size-4',
|
className = 'size-4',
|
||||||
size,
|
size,
|
||||||
|
trademark = true,
|
||||||
state = 'default',
|
state = 'default',
|
||||||
...props
|
...props
|
||||||
}: AIChatIconProps) => {
|
}: AIChatIconProps) => {
|
||||||
|
if (!trademark) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
icon="sparkle"
|
||||||
|
className={tcls(
|
||||||
|
className,
|
||||||
|
(state === 'thinking' || state === 'working') &&
|
||||||
|
'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||||
|
state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
|
preserveAspectRatio="xMaxYMid meet"
|
||||||
|
className={className}
|
||||||
|
aria-busy={state === 'thinking'}
|
||||||
|
{...props}
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
|
||||||
{...(size && { width: size, height: size })}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<title>Docs Assistant</title>
|
<title>GitBook Assistant</title>
|
||||||
|
|
||||||
|
{/* Sparkle */}
|
||||||
<path
|
<path
|
||||||
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
|
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={state === 'thinking' ? 'animate-spin' : ''}
|
className={tcls(
|
||||||
|
state === 'intro' &&
|
||||||
|
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
|
||||||
|
(state === 'working' || state === 'thinking') &&
|
||||||
|
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||||
|
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||||
|
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||||
|
state === 'error' && 'hidden'
|
||||||
|
)}
|
||||||
style={{ transformOrigin: '13px 3.5px' }}
|
style={{ transformOrigin: '13px 3.5px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
<g
|
||||||
|
clipPath="url(#clip0_153_2034)"
|
||||||
|
className={tcls(
|
||||||
|
'text-danger-subtle',
|
||||||
|
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M9.479 10.2734C9.479 11.2369 8.6115 11.2578 7.54138 11.2578C6.47126 11.2578 5.60376 11.2369 5.60376 10.2734"
|
d="M13.0312 1.42059L13.0312 3.95184"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M9.47913 6.77344L9.47913 7.77344"
|
d="M13.0312 6.00253V6.00263"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Check */}
|
||||||
<path
|
<path
|
||||||
d="M5.60388 6.77344L5.60388 7.77344"
|
d="M10.8051 3.71161L12.2401 5.27411L14.823 2.14911"
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M13.2813 8.875L13.2813 10.125C13.2813 12.3341 11.4904 14.125 9.28125 14.125L1.80165 14.125L1.80165 6.78125C1.80165 4.57211 3.59251 2.78125 5.80165 2.78125L7.67889 2.78125"
|
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
className={tcls(
|
||||||
|
state === 'done'
|
||||||
|
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||||
|
: 'animate-[fadeOut_.5s_both]',
|
||||||
|
state === 'intro' && 'hidden'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Background */}
|
||||||
|
<path
|
||||||
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity="0.25"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<mask
|
||||||
|
id="mask0_220_25"
|
||||||
|
style={{ maskType: 'alpha' }}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="1"
|
||||||
|
width="16"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||||
|
stroke="currentColor"
|
||||||
|
pathLength="100"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_220_25)">
|
||||||
|
<path
|
||||||
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L14.1991 4.90434L9.37937 2.12165C8.76057 1.76438 7.99907 1.76386 7.38027 2.12113C5.89314 2.97972 3.20298 4.53289 2.5596 4.90434C1.77376 5.35804 1 6.11597 1.00148 7.9207"
|
||||||
|
stroke="currentColor"
|
||||||
|
pathLength="100"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={tcls(
|
||||||
|
(state === 'thinking' || state === 'working') &&
|
||||||
|
'animate-[pathLoading_2s_infinite_both]',
|
||||||
|
state === 'intro' && 'animate-[pathEnter_2s_both]',
|
||||||
|
state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AIChatIcon;
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { t, tString, useLanguage } from '@/intl/client';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { Tooltip } from '../primitives/Tooltip';
|
import { Tooltip } from '../primitives/Tooltip';
|
||||||
|
|
||||||
export function AIChatInput(props: {
|
export function AIChatInput(props: {
|
||||||
@@ -31,12 +33,25 @@ export function AIChatInput(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disabled) {
|
if (!disabled && !loading) {
|
||||||
setTimeout(() => {
|
// Add a small delay to ensure the input is rendered before focusing
|
||||||
|
// This fixes inconsistent focus behaviour across browsers
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 300);
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled, loading]);
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'mod+j',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
||||||
@@ -49,11 +64,12 @@ export function AIChatInput(props: {
|
|||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'focus:ring-0',
|
'focus:ring-0',
|
||||||
'w-full',
|
'w-full',
|
||||||
'px-4',
|
'px-3',
|
||||||
'py-3',
|
'py-3',
|
||||||
'pb-12',
|
'pb-12',
|
||||||
'h-auto',
|
'h-auto',
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
|
'peer',
|
||||||
'max-h-64',
|
'max-h-64',
|
||||||
'placeholder:text-tint/8',
|
'placeholder:text-tint/8',
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
@@ -69,6 +85,12 @@ export function AIChatInput(props: {
|
|||||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
|
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.style.height = 'auto';
|
event.currentTarget.style.height = 'auto';
|
||||||
@@ -76,6 +98,9 @@ export function AIChatInput(props: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||||
|
<KeyboardShortcut keys={['mod', 'j']} />
|
||||||
|
</div>
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
@@ -101,8 +126,8 @@ export function AIChatInput(props: {
|
|||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
|
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||||
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
<span className="-ml-1 rounded-corners:rounded circular-corners:rounded-2xl bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||||
AI
|
{t(language, 'ai_chat_context_badge')}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span>{t(language, 'ai_chat_context_title')}</span>
|
<span>{t(language, 'ai_chat_context_title')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { AIChatState } from '../AI/useAIChat';
|
import type { AIChatController, AIChatState } from '../AI/useAIChat';
|
||||||
|
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
|
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||||
|
|
||||||
export function AIChatMessages(props: {
|
export function AIChatMessages(props: {
|
||||||
chat: AIChatState;
|
chat: AIChatState;
|
||||||
|
chatController: AIChatController;
|
||||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||||
}) {
|
}) {
|
||||||
const { chat, lastUserMessageRef } = props;
|
const { chat, chatController, lastUserMessageRef } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{chat.messages.map((message, index) => {
|
{chat.messages.map((message, index) => {
|
||||||
|
const isLastMessage = index === chat.messages.length - 1;
|
||||||
const isLastUserMessage =
|
const isLastUserMessage =
|
||||||
message.role === AIMessageRole.User &&
|
message.role === AIMessageRole.User &&
|
||||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
||||||
@@ -20,26 +24,30 @@ export function AIChatMessages(props: {
|
|||||||
<div
|
<div
|
||||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
message.content ? 'animate-[fadeIn_500ms_both]' : '',
|
message.content ? 'animate-fadeIn-slow' : '',
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
'last:min-h-[calc(100%-5rem)]',
|
'last:min-h-[calc(100%-5rem)]',
|
||||||
|
'scroll-mt-36',
|
||||||
|
'lg:scroll-mt-0',
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'group/message',
|
||||||
message.role === AIMessageRole.User
|
message.role === AIMessageRole.User
|
||||||
? 'max-w-[80%] self-end rounded-md bg-tint px-4 py-2'
|
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||||
: ''
|
: 'text-tint-strong'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||||
}}
|
}}
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
{message.content ? (
|
{message.content ? message.content : null}
|
||||||
message.content
|
|
||||||
) : chat.loading ? (
|
{isLastMessage && chat.loading ? (
|
||||||
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2">
|
<div className="flex w-full animate-fadeIn-slow flex-wrap gap-2 group-has-[.has-content]/message:hidden">
|
||||||
{Array.from({ length: 7 }).map((_, index) => (
|
{Array.from({ length: 7 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="h-4 animate-[fadeIn_0.5s_ease_both,pulse_1.5s_infinite] rounded-md bg-tint-4"
|
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||||
style={{
|
style={{
|
||||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||||
animationDelay: `${index * 0.1}s`,
|
animationDelay: `${index * 0.1}s`,
|
||||||
@@ -48,6 +56,22 @@ export function AIChatMessages(props: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isLastMessage ? (
|
||||||
|
<>
|
||||||
|
{!chat.loading && !chat.error && chat.query && chat.responseId && (
|
||||||
|
<AIResponseFeedback
|
||||||
|
responseId={chat.responseId}
|
||||||
|
query={chat.query}
|
||||||
|
className="-ml-1 -mt-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AIChatFollowupSuggestions
|
||||||
|
chat={chat}
|
||||||
|
chatController={chatController}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t, tString } from '@/intl/translate';
|
||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
|
export function AIResponseFeedback(props: {
|
||||||
|
className?: ClassValue;
|
||||||
|
responseId: string;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
const { className, responseId, query } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const [rating, setRating] = useState<1 | -1 | null>(null);
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
|
const handleRating = (rating: 1 | -1) => {
|
||||||
|
setRating(rating);
|
||||||
|
trackEvent({ type: 'ask_rate_response', query, responseId, rating });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={tcls('flex h-fit items-center', className)}>
|
||||||
|
<Button
|
||||||
|
icon="thumbs-up"
|
||||||
|
iconOnly
|
||||||
|
label={tString(language, 'was_this_helpful_positive_label')}
|
||||||
|
variant="blank"
|
||||||
|
className={tcls(
|
||||||
|
'animate-fadeIn overflow-hidden text-tint-subtle transition-all',
|
||||||
|
rating !== null && rating !== 1 && 'px-0 text-[0] opacity-0'
|
||||||
|
)}
|
||||||
|
size="medium"
|
||||||
|
style={{ animationDuration: '.5s' }}
|
||||||
|
onClick={() => handleRating(1)}
|
||||||
|
disabled={rating !== null}
|
||||||
|
active={rating === 1}
|
||||||
|
key="positive"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="thumbs-down"
|
||||||
|
iconOnly
|
||||||
|
label={tString(language, 'was_this_helpful_negative_label')}
|
||||||
|
variant="blank"
|
||||||
|
className={tcls(
|
||||||
|
'animate-fadeIn overflow-hidden text-tint-subtle transition-all',
|
||||||
|
rating !== null && rating !== -1 && 'px-0 text-[0] opacity-0'
|
||||||
|
)}
|
||||||
|
size="medium"
|
||||||
|
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
|
||||||
|
onClick={() => handleRating(-1)}
|
||||||
|
disabled={rating !== null}
|
||||||
|
active={rating === -1}
|
||||||
|
key="negative"
|
||||||
|
/>
|
||||||
|
{rating !== null ? (
|
||||||
|
<span
|
||||||
|
className="ml-2 animate-fadeIn-slow text-tint-subtle"
|
||||||
|
style={{ animationDelay: '.3s' }}
|
||||||
|
>
|
||||||
|
{t(language, 'was_this_helpful_thank_you')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row flex-wrap justify-end gap-2 pt-8">
|
<div className="mt-auto flex w-full flex-wrap justify-end gap-2">
|
||||||
{chat.followUpSuggestions.map((suggestion, index) => (
|
{chat.followUpSuggestions.map((suggestion, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={index}
|
key={index}
|
||||||
@@ -23,9 +23,9 @@ export function AIChatFollowupSuggestions(props: {
|
|||||||
chatController.postMessage({ message: suggestion });
|
chatController.postMessage({ message: suggestion });
|
||||||
}}
|
}}
|
||||||
label={suggestion}
|
label={suggestion}
|
||||||
className="max-w-full animate-[present_500ms_both] whitespace-normal text-right"
|
className="!whitespace-normal max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
||||||
size="small"
|
size="medium"
|
||||||
variant="secondary"
|
variant="blank"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export * from './AIChat';
|
export * from './AIChat';
|
||||||
|
export * from './AIChatButton';
|
||||||
|
export * from './AIChatIcon';
|
||||||
|
export * from './AIResponseFeedback';
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export function AnnouncementBanner(props: {
|
|||||||
const style = BANNER_STYLES[announcement.style];
|
const style = BANNER_STYLES[announcement.style];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
|
<div
|
||||||
|
id="announcement-banner"
|
||||||
|
className="theme-bold:bg-header-background pt-4 pb-2"
|
||||||
|
data-nosnippet=""
|
||||||
|
>
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
|||||||
isOffscreen?: boolean;
|
isOffscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Blocks that can be full width are automatically expanded on full-width pages.
|
||||||
|
* Ideally we'd rely on the block type to determine if it can be full width, but
|
||||||
|
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
|
||||||
|
* So instead we hardcode a list of blocks that can be full width. */
|
||||||
|
const FULL_WIDTH_BLOCKS = [
|
||||||
|
'table',
|
||||||
|
'tabs',
|
||||||
|
'integration',
|
||||||
|
'openapi',
|
||||||
|
'images',
|
||||||
|
'embed',
|
||||||
|
'columns',
|
||||||
|
'code',
|
||||||
|
'content-ref',
|
||||||
|
'hint',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a list of blocks without a wrapper element.
|
* Renders a list of blocks without a wrapper element.
|
||||||
*/
|
*/
|
||||||
@@ -68,10 +85,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
|||||||
key={node.key || `${node.type}-${index}`}
|
key={node.key || `${node.type}-${index}`}
|
||||||
block={node}
|
block={node}
|
||||||
style={[
|
style={[
|
||||||
'mx-auto w-full decoration-primary/6',
|
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||||
? 'max-w-screen-2xl'
|
? 'max-w-screen-2xl'
|
||||||
: 'page-full-width:ml-0 max-w-3xl',
|
: 'max-w-3xl',
|
||||||
|
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
|
||||||
blockStyle,
|
blockStyle,
|
||||||
]}
|
]}
|
||||||
isEstimatedOffscreen={isOffscreen}
|
isEstimatedOffscreen={isOffscreen}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
|||||||
document={document}
|
document={document}
|
||||||
ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
|
ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
|
||||||
context={context}
|
context={context}
|
||||||
style="w-full space-y-4 *:max-w-full"
|
style="group/column w-full space-y-4 *:max-w-full"
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
|
|||||||
export function Divider(props: BlockProps<DocumentBlockDivider>) {
|
export function Divider(props: BlockProps<DocumentBlockDivider>) {
|
||||||
const { style } = props;
|
const { style } = props;
|
||||||
|
|
||||||
return <hr className={tcls(style, 'page-full-width:max-w-full border-tint-subtle')} />;
|
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,28 +24,32 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
textStyle.textSize,
|
textStyle.textSize,
|
||||||
'heading',
|
'heading',
|
||||||
'grid',
|
'flex',
|
||||||
|
'items-baseline',
|
||||||
'scroll-m-12',
|
'scroll-m-12',
|
||||||
|
getTextAlignment(block.data.align),
|
||||||
hashLinkButtonWrapperStyles,
|
hashLinkButtonWrapperStyles,
|
||||||
style
|
style,
|
||||||
|
textStyle.marginTop
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HashLinkButton
|
<HashLinkButton
|
||||||
id={id}
|
id={id}
|
||||||
block={block}
|
block={block}
|
||||||
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
|
className={tcls('-ml-6', 'pr-2')}
|
||||||
iconClassName={tcls('size-4')}
|
iconClassName={tcls('size-4')}
|
||||||
label="Direct link to heading"
|
label="Direct link to heading"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'grid-area-1-1',
|
'flex-1',
|
||||||
'z-[1]',
|
'z-[1]',
|
||||||
'justify-self-start',
|
'justify-self-start',
|
||||||
|
'max-w-full',
|
||||||
|
'break-words',
|
||||||
getTextAlignment(block.data.align),
|
getTextAlignment(block.data.align),
|
||||||
textStyle.lineHeight,
|
textStyle.lineHeight
|
||||||
textStyle.marginTop
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
|
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
|
|||||||
{hasHeading ? (
|
{hasHeading ? (
|
||||||
<Block
|
<Block
|
||||||
style={tcls(
|
style={tcls(
|
||||||
'flip-heading-hash p-4 pl-3 text-[1em] *:mt-0',
|
'!py-4 w-full items-start pl-3 text-[1em] *:flex-none',
|
||||||
hasHeading ? hintStyle.header : null
|
// Heading hash styles
|
||||||
|
'flip-heading-hash pr-8',
|
||||||
|
hintStyle.header
|
||||||
)}
|
)}
|
||||||
ancestorBlocks={[...ancestorBlocks, block]}
|
ancestorBlocks={[...ancestorBlocks, block]}
|
||||||
{...contextProps}
|
{...contextProps}
|
||||||
|
|||||||
@@ -195,3 +195,18 @@
|
|||||||
.contentkit-hstack > .contentkit-divider-large {
|
.contentkit-hstack > .contentkit-divider-large {
|
||||||
@apply mx-4;
|
@apply mx-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confirmation modal */
|
||||||
|
|
||||||
|
.contentkit-modal-confirm {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
.contentkit-modal-confirm .contentkit-modal-footer {
|
||||||
|
@apply flex gap-2 justify-end;
|
||||||
|
}
|
||||||
|
.contentkit-button-confirm {
|
||||||
|
@apply w-auto flex-grow-0 flex-shrink-0;
|
||||||
|
}
|
||||||
|
.contentkit-button-style-danger {
|
||||||
|
@apply bg-danger text-danger hover:bg-danger-hover hover:text-danger-strong contrast-more:bg-tint-subtle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
|
|||||||
// flip heading hash icon if list item is a heading
|
// flip heading hash icon if list item is a heading
|
||||||
'flip-heading-hash',
|
'flip-heading-hash',
|
||||||
// remove margin-top for the first heading in a list
|
// remove margin-top for the first heading in a list
|
||||||
'[&:is(h2)>div]:mt-0',
|
'[&:is(h2)]:pt-0',
|
||||||
'[&:is(h3)>div]:mt-0',
|
'[&:is(h3)]:pt-0',
|
||||||
'[&:is(h4)>div]:mt-0',
|
'[&:is(h4)]:pt-0',
|
||||||
// Override the "mx-auto" class from UnwrappedBlocks
|
// Override the "mx-auto" class from UnwrappedBlocks
|
||||||
'mx-0'
|
'mx-0'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function getOpenAPIContext(args: {
|
|||||||
style={tcls([
|
style={tcls([
|
||||||
headingProps.deprecated ? 'line-through' : undefined,
|
headingProps.deprecated ? 'line-through' : undefined,
|
||||||
headingProps.deprecated || !!headingProps.stability
|
headingProps.deprecated || !!headingProps.stability
|
||||||
? '[&>div]:mt-0'
|
? '[&>div]:pt-0'
|
||||||
: undefined,
|
: undefined,
|
||||||
])}
|
])}
|
||||||
block={{
|
block={{
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export async function RecordCard(
|
|||||||
'[&_.heading>div:first-child]:hidden',
|
'[&_.heading>div:first-child]:hidden',
|
||||||
'[&_.heading>div]:text-[.8em]',
|
'[&_.heading>div]:text-[.8em]',
|
||||||
'md:[&_.heading>div]:text-[1em]',
|
'md:[&_.heading>div]:text-[1em]',
|
||||||
'[&_.blocks:first-child_.heading:first-child_div]:mt-0', // Remove margin on first heading in card
|
'[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
|
||||||
|
|
||||||
// On mobile, check if we can display the cover responsively or not:
|
// On mobile, check if we can display the cover responsively or not:
|
||||||
// - If the file has a landscape aspect ratio, we display it normally
|
// - If the file has a landscape aspect ratio, we display it normally
|
||||||
|
|||||||
@@ -126,7 +126,25 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
|
/**
|
||||||
|
* @TODO replace by DocumentMarkColor['data']['text'] and DocumentMarkColor['data']['background']
|
||||||
|
* once the API is updated.
|
||||||
|
*/
|
||||||
|
type DocumentMarkColorValue =
|
||||||
|
| 'default'
|
||||||
|
| 'green'
|
||||||
|
| 'blue'
|
||||||
|
| 'red'
|
||||||
|
| 'orange'
|
||||||
|
| 'yellow'
|
||||||
|
| 'purple'
|
||||||
|
| '$primary'
|
||||||
|
| '$info'
|
||||||
|
| '$success'
|
||||||
|
| '$warning'
|
||||||
|
| '$danger';
|
||||||
|
|
||||||
|
const textColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
|
||||||
default: [],
|
default: [],
|
||||||
blue: ['text-blue-500'],
|
blue: ['text-blue-500'],
|
||||||
red: ['text-red-500'],
|
red: ['text-red-500'],
|
||||||
@@ -134,9 +152,14 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
|
|||||||
yellow: ['text-yellow-600'],
|
yellow: ['text-yellow-600'],
|
||||||
purple: ['text-purple-500'],
|
purple: ['text-purple-500'],
|
||||||
orange: ['text-orange-500'],
|
orange: ['text-orange-500'],
|
||||||
|
$primary: ['text-primary'],
|
||||||
|
$info: ['text-info'],
|
||||||
|
$success: ['text-success'],
|
||||||
|
$warning: ['text-warning'],
|
||||||
|
$danger: ['text-danger'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background']]: ClassValue } = {
|
const backgroundColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
|
||||||
default: [],
|
default: [],
|
||||||
blue: ['bg-mark-blue'],
|
blue: ['bg-mark-blue'],
|
||||||
red: ['bg-mark-red'],
|
red: ['bg-mark-red'],
|
||||||
@@ -144,4 +167,9 @@ const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background'
|
|||||||
yellow: ['bg-mark-yellow'],
|
yellow: ['bg-mark-yellow'],
|
||||||
purple: ['bg-mark-purple'],
|
purple: ['bg-mark-purple'],
|
||||||
orange: ['bg-mark-orange'],
|
orange: ['bg-mark-orange'],
|
||||||
|
$primary: ['bg-primary'],
|
||||||
|
$info: ['bg-info'],
|
||||||
|
$success: ['bg-success'],
|
||||||
|
$warning: ['bg-warning'],
|
||||||
|
$danger: ['bg-danger'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export function getBlockTextStyle(block: DocumentBlock): {
|
|||||||
lineHeight: string;
|
lineHeight: string;
|
||||||
/** Tailwind class for the margin top (mt-*) */
|
/** Tailwind class for the margin top (mt-*) */
|
||||||
marginTop?: string;
|
marginTop?: string;
|
||||||
/** Tailwind class for the margin top to apply on the anchor link button */
|
|
||||||
anchorButtonMarginTop?: string;
|
|
||||||
} {
|
} {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
@@ -23,22 +21,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
|
|||||||
return {
|
return {
|
||||||
textSize: 'text-3xl font-semibold',
|
textSize: 'text-3xl font-semibold',
|
||||||
lineHeight: 'leading-tight',
|
lineHeight: 'leading-tight',
|
||||||
marginTop: 'mt-[1em]',
|
marginTop: 'column-first-of-type:pt-0 pt-[1em]',
|
||||||
anchorButtonMarginTop: 'mt-[1.05em]',
|
|
||||||
};
|
};
|
||||||
case 'heading-2':
|
case 'heading-2':
|
||||||
return {
|
return {
|
||||||
textSize: 'text-2xl font-semibold',
|
textSize: 'text-2xl font-semibold',
|
||||||
lineHeight: 'leading-snug',
|
lineHeight: 'leading-snug',
|
||||||
marginTop: 'mt-[0.75em]',
|
marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
|
||||||
anchorButtonMarginTop: 'mt-[0.9em]',
|
|
||||||
};
|
};
|
||||||
case 'heading-3':
|
case 'heading-3':
|
||||||
return {
|
return {
|
||||||
textSize: 'text-xl font-semibold',
|
textSize: 'text-xl font-semibold',
|
||||||
lineHeight: 'leading-snug',
|
lineHeight: 'leading-snug',
|
||||||
marginTop: 'mt-[0.5em]',
|
marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
|
||||||
anchorButtonMarginTop: 'mt-[0.65em]',
|
|
||||||
};
|
};
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
|||||||
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
|
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'lg:!max-w-none mx-auto grid max-w-3xl site-full-width:max-w-screen-2xl justify-between gap-12',
|
'lg:!max-w-none mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12',
|
||||||
'grid-cols-[auto_auto]',
|
'grid-cols-[auto_auto]',
|
||||||
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
|
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
|
||||||
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
|
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
|
||||||
'site-full-width:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
|
'site-width-wide:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]',
|
||||||
'site-full-width:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
|
'site-width-wide:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]',
|
||||||
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
|
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
|
||||||
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]',
|
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]',
|
||||||
'[body:has(.site-full-width,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
|
'[body:has(.site-width-wide,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]',
|
||||||
'[body:has(.site-full-width,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
|
'[body:has(.site-width-wide,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
@@ -106,7 +106,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
|||||||
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
|
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-3xl site-full-width:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
|
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
|
||||||
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
|
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
|
||||||
(column, columnIndex) => (
|
(column, columnIndex) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||||
import { getSpaceLanguage, t } from '@/intl/server';
|
import { getSpaceLanguage, t } from '@/intl/server';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
|
||||||
import { AIChatButton } from '../AIChat/AIChatButton';
|
|
||||||
import { SearchButton } from '../Search';
|
|
||||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { HeaderLink } from './HeaderLink';
|
import { HeaderLink } from './HeaderLink';
|
||||||
import { HeaderLinkMore } from './HeaderLinkMore';
|
import { HeaderLinkMore } from './HeaderLinkMore';
|
||||||
@@ -22,9 +17,9 @@ import { SpacesDropdown } from './SpacesDropdown';
|
|||||||
export function Header(props: {
|
export function Header(props: {
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
withTopHeader?: boolean;
|
withTopHeader?: boolean;
|
||||||
withAIChat?: boolean;
|
search?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { context, withTopHeader, withAIChat } = props;
|
const { context, withTopHeader, search } = props;
|
||||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,50 +118,7 @@ export function Header(props: {
|
|||||||
: ['order-last']
|
: ['order-last']
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
{search}
|
||||||
<SearchButton
|
|
||||||
style={[
|
|
||||||
'theme-bold:bg-header-link/2',
|
|
||||||
'theme-bold:hover:bg-header-link/3',
|
|
||||||
|
|
||||||
'theme-bold:text-header-link/8',
|
|
||||||
'theme-bold:hover:text-header-link',
|
|
||||||
|
|
||||||
'theme-bold:ring-header-link/4',
|
|
||||||
'theme-bold:hover:ring-header-link/5',
|
|
||||||
|
|
||||||
'theme-bold:[&_svg]:text-header-link/10',
|
|
||||||
'theme-bold:[&_.shortcut]:text-header-link/8',
|
|
||||||
|
|
||||||
'theme-bold:contrast-more:bg-header-background',
|
|
||||||
'theme-bold:contrast-more:text-header-link',
|
|
||||||
'theme-bold:contrast-more:ring-header-link',
|
|
||||||
'theme-bold:contrast-more:hover:bg-header-background',
|
|
||||||
'theme-bold:contrast-more:hover:ring-header-link',
|
|
||||||
'theme-bold:contrast-more:focus:text-header-link',
|
|
||||||
'theme-bold:contrast-more:focus:bg-header-background',
|
|
||||||
'theme-bold:contrast-more:focus:ring-header-link',
|
|
||||||
|
|
||||||
'theme-bold:shadow-none',
|
|
||||||
'theme-bold:hover:shadow-none',
|
|
||||||
'whitespace-nowrap',
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<span className={tcls('flex-1')}>
|
|
||||||
{t(
|
|
||||||
getSpaceLanguage(customization),
|
|
||||||
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
|
|
||||||
customization.aiSearch?.enabled ||
|
|
||||||
customization.ai?.mode !==
|
|
||||||
CustomizationAIMode.None
|
|
||||||
? 'search_or_ask'
|
|
||||||
: 'search'
|
|
||||||
)}
|
|
||||||
...
|
|
||||||
</span>
|
|
||||||
</SearchButton>
|
|
||||||
</Suspense>
|
|
||||||
{withAIChat && <AIChatButton />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{customization.header.links.length > 0 && (
|
{customization.header.links.length > 0 && (
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function HeaderItemButton(
|
|||||||
const variant = (() => {
|
const variant = (() => {
|
||||||
switch (linkStyle) {
|
switch (linkStyle) {
|
||||||
case 'button-secondary':
|
case 'button-secondary':
|
||||||
return 'secondary';
|
return 'header';
|
||||||
case 'button-primary':
|
case 'button-primary':
|
||||||
return 'primary';
|
return 'primary';
|
||||||
default:
|
default:
|
||||||
@@ -120,9 +120,7 @@ function HeaderItemButton(
|
|||||||
{
|
{
|
||||||
'button-primary':
|
'button-primary':
|
||||||
'theme-bold:bg-header-link theme-bold:text-header-background theme-bold:shadow-none theme-bold:hover:bg-header-link theme-bold:hover:text-header-background theme-bold:hover:shadow-none',
|
'theme-bold:bg-header-link theme-bold:text-header-background theme-bold:shadow-none theme-bold:hover:bg-header-link theme-bold:hover:text-header-background theme-bold:hover:shadow-none',
|
||||||
'button-secondary': tcls(
|
'button-secondary': '',
|
||||||
'theme-bold:bg-header-link/2 theme-gradient:bg-tint-base theme-muted:bg-tint-base theme-bold:text-header-link theme-bold:shadow-none theme-bold:ring-header-link/4 theme-bold:hover:bg-header-link/3 theme-bold:hover:text-header-link theme-bold:hover:shadow-none theme-bold:hover:ring-header-link/5 theme-bold:contrast-more:bg-header-background theme-bold:contrast-more:text-header-link theme-bold:contrast-more:ring-header-link theme-bold:contrast-more:hover:ring-header-link'
|
|
||||||
),
|
|
||||||
}[linkStyle]
|
}[linkStyle]
|
||||||
)}
|
)}
|
||||||
insights={{
|
insights={{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={linker.toAbsoluteURL(linker.toPathInSite(''))}
|
href={linker.toPathInSite('')}
|
||||||
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
||||||
>
|
>
|
||||||
{customization.header.logo ? (
|
{customization.header.logo ? (
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function PageAside(props: {
|
|||||||
'chat-open:xl:max-3xl:max-w-0',
|
'chat-open:xl:max-3xl:max-w-0',
|
||||||
'chat-open:xl:max-3xl:ml-0',
|
'chat-open:xl:max-3xl:ml-0',
|
||||||
|
|
||||||
'motion-safe:transition-all motion-safe:duration-300',
|
'motion-safe:xl:transition-all motion-safe:xl:duration-300',
|
||||||
'motion-safe:[transition-behavior:allow-discrete]',
|
'motion-safe:[transition-behavior:allow-discrete]',
|
||||||
|
|
||||||
'flex-col',
|
'flex-col',
|
||||||
@@ -95,8 +95,9 @@ export function PageAside(props: {
|
|||||||
'page-api-block:xl:max-2xl:dark:hover:shadow-tint-1/1',
|
'page-api-block:xl:max-2xl:dark:hover:shadow-tint-1/1',
|
||||||
'page-api-block:xl:max-2xl:rounded-md',
|
'page-api-block:xl:max-2xl:rounded-md',
|
||||||
'page-api-block:xl:max-2xl:h-auto',
|
'page-api-block:xl:max-2xl:h-auto',
|
||||||
'page-api-block:xl:max-2xl:my-4',
|
'page-api-block:xl:max-2xl:my-9',
|
||||||
'page-api-block:p-2'
|
'page-api-block:px-2',
|
||||||
|
'page-api-block:py-1.5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page.layout.outline ? (
|
{page.layout.outline ? (
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export function PageBody(props: {
|
|||||||
LINK_PREVIEW_MAX_COUNT
|
LINK_PREVIEW_MAX_COUNT
|
||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
|
const pageWidthWide = page.layout.width === 'wide';
|
||||||
const asFullWidth = pageFullWidth || contentFullWidth;
|
const siteWidthWide = pageWidthWide || contentFullWidth;
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||||
|
|
||||||
@@ -53,12 +53,12 @@ export function PageBody(props: {
|
|||||||
'mx-auto max-w-screen-2xl py-8',
|
'mx-auto max-w-screen-2xl py-8',
|
||||||
// Allow words to break if they are too long.
|
// Allow words to break if they are too long.
|
||||||
'break-anywhere',
|
'break-anywhere',
|
||||||
pageFullWidth ? 'page-full-width 2xl:px-8' : 'page-default-width',
|
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
||||||
asFullWidth ? 'site-full-width' : 'site-default-width',
|
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||||
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PreservePageLayout asFullWidth={asFullWidth} />
|
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
||||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -93,10 +93,17 @@ export function PageBody(props: {
|
|||||||
<PageFooterNavigation context={context} page={page} />
|
<PageFooterNavigation context={context} page={page} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{
|
||||||
|
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
|
||||||
|
page.layout.metadata !== false ? (
|
||||||
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
|
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
|
||||||
{updatedAt ? (
|
{updatedAt ? (
|
||||||
<p className="mr-auto text-sm">
|
<p className="mr-auto text-sm ">
|
||||||
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
|
{t(
|
||||||
|
language,
|
||||||
|
'page_last_modified',
|
||||||
|
<DateRelative value={updatedAt} />
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{withPageFeedback ? (
|
{withPageFeedback ? (
|
||||||
@@ -106,6 +113,8 @@ export function PageBody(props: {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TrackPageViewEvent />
|
<TrackPageViewEvent />
|
||||||
|
|||||||
@@ -71,11 +71,13 @@ export async function PageBodyBlankslate(props: {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
'grid',
|
'grid',
|
||||||
'max-w-3xl',
|
'max-w-3xl',
|
||||||
|
'page-width-wide:max-w-screen-2xl',
|
||||||
'w-full',
|
'w-full',
|
||||||
'mx-auto',
|
'mx-auto',
|
||||||
'gap-4',
|
'gap-4',
|
||||||
'grid-cols-1',
|
'grid-cols-1',
|
||||||
'sm:grid-cols-2'
|
'sm:grid-cols-2',
|
||||||
|
'page-width-wide:md:grid-cols-3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pageElements}
|
{pageElements}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function PageCover(props: {
|
|||||||
: [
|
: [
|
||||||
'sm:mx-auto',
|
'sm:mx-auto',
|
||||||
'max-w-3xl ',
|
'max-w-3xl ',
|
||||||
'page-full-width:max-w-screen-2xl',
|
'page-width-wide:max-w-screen-2xl',
|
||||||
'sm:rounded-md',
|
'sm:rounded-md',
|
||||||
'mb-8',
|
'mb-8',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function PageFooterNavigation(props: {
|
|||||||
'mt-6',
|
'mt-6',
|
||||||
'gap-2',
|
'gap-2',
|
||||||
'max-w-3xl',
|
'max-w-3xl',
|
||||||
'page-full-width:max-w-screen-2xl',
|
'page-width-wide:max-w-screen-2xl',
|
||||||
'mx-auto',
|
'mx-auto',
|
||||||
'text-tint'
|
'text-tint'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { RevisionPageDocument } from '@gitbook/api';
|
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||||
import { Fragment } from 'react';
|
|
||||||
|
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import type { RevisionPageDocument } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
import { PageIcon } from '../PageIcon';
|
import { PageIcon } from '../PageIcon';
|
||||||
import { StyledLink } from '../primitives';
|
import { StyledLink } from '../primitives';
|
||||||
|
|
||||||
@@ -21,19 +20,45 @@ export async function PageHeader(props: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withAIChat = isAIChatEnabled(context);
|
||||||
|
|
||||||
|
const pageActions = context.customization.pageActions ?? {
|
||||||
|
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
|
||||||
|
markdown: true,
|
||||||
|
externalAI: true,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'max-w-3xl',
|
'max-w-3xl',
|
||||||
'page-full-width:max-w-screen-2xl',
|
'page-width-wide:max-w-screen-2xl',
|
||||||
'mx-auto',
|
'mx-auto',
|
||||||
'mb-6',
|
'mb-6',
|
||||||
'space-y-3',
|
'space-y-3',
|
||||||
'page-api-block:ml-0'
|
'page-api-block:ml-0',
|
||||||
|
'page-api-block:max-w-full'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{page.layout.tableOfContents &&
|
||||||
|
// Show page actions if *any* of the actions are enabled
|
||||||
|
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'float-right mb-2 ml-4 page-api-block:xl:max-2xl:mr-[14.5rem]',
|
||||||
|
ancestors.length > 0 ? '-mt-2' : 'xs:mt-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AIActionsDropdown
|
||||||
|
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||||
|
withAIChat={withAIChat}
|
||||||
|
trademark={context.customization.trademark.enabled}
|
||||||
|
actions={pageActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{ancestors.length > 0 && (
|
{ancestors.length > 0 && (
|
||||||
<nav>
|
<nav aria-label="Breadcrumb">
|
||||||
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
|
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
|
||||||
{ancestors.map((breadcrumb, index) => {
|
{ancestors.map((breadcrumb, index) => {
|
||||||
const href = linker.toPathForPage({
|
const href = linker.toPathForPage({
|
||||||
@@ -41,8 +66,7 @@ export async function PageHeader(props: {
|
|||||||
page: breadcrumb,
|
page: breadcrumb,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Fragment key={breadcrumb.id}>
|
<li key={breadcrumb.id} className="flex items-center gap-2">
|
||||||
<li key={breadcrumb.id}>
|
|
||||||
<StyledLink
|
<StyledLink
|
||||||
href={href}
|
href={href}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
@@ -65,27 +89,39 @@ export async function PageHeader(props: {
|
|||||||
/>
|
/>
|
||||||
{breadcrumb.title}
|
{breadcrumb.title}
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
</li>
|
|
||||||
{index !== ancestors.length - 1 && (
|
{index !== ancestors.length - 1 && (
|
||||||
<Icon
|
<Icon
|
||||||
|
aria-hidden
|
||||||
icon="chevron-right"
|
icon="chevron-right"
|
||||||
className={tcls('size-3', 'text-tint-subtle')}
|
className="size-3 text-tint-subtle"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
{page.layout.title ? (
|
{page.layout.title ? (
|
||||||
<h1 className={tcls('text-4xl', 'font-bold', 'flex', 'items-center', 'gap-4')}>
|
<h1
|
||||||
|
className={tcls(
|
||||||
|
'text-4xl',
|
||||||
|
'font-bold',
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'gap-4',
|
||||||
|
'grow',
|
||||||
|
'text-pretty',
|
||||||
|
'clear-right',
|
||||||
|
'xs:clear-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
||||||
{page.title}
|
{page.title}
|
||||||
</h1>
|
</h1>
|
||||||
) : null}
|
) : null}
|
||||||
{page.description && page.layout.description ? (
|
{page.description && page.layout.description ? (
|
||||||
<p className={tcls('text-lg', 'text-tint')}>{page.description}</p>
|
<p className={tcls('text-lg', 'text-tint', 'clear-both')}>{page.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import * as React from 'react';
|
|||||||
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
|
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
|
||||||
* which becomes false while transitioning between the 2 page states:
|
* which becomes false while transitioning between the 2 page states:
|
||||||
*
|
*
|
||||||
* 1. Page 1 with full width block: `body:has(.site-full-width)` is true
|
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
|
||||||
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-full-width)` is false
|
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
|
||||||
* 3. Page 2 with full width block: `body:has(.site-full-width)` is true
|
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
|
||||||
*
|
*
|
||||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||||
*/
|
*/
|
||||||
export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||||
const { asFullWidth } = props;
|
const { siteWidthWide } = props;
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
// We use the header as it's an element preserved between page transitions
|
// We use the header as it's an element preserved between page transitions
|
||||||
@@ -23,12 +23,12 @@ export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asFullWidth) {
|
if (siteWidthWide) {
|
||||||
header.classList.add('site-full-width');
|
header.classList.add('site-width-wide');
|
||||||
} else {
|
} else {
|
||||||
header.classList.remove('site-full-width');
|
header.classList.remove('site-width-wide');
|
||||||
}
|
}
|
||||||
}, [asFullWidth]);
|
}, [siteWidthWide]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CustomizationHeaderPreset,
|
|
||||||
CustomizationIconsStyle,
|
CustomizationIconsStyle,
|
||||||
CustomizationSidebarBackgroundStyle,
|
CustomizationSidebarBackgroundStyle,
|
||||||
CustomizationSidebarListStyle,
|
CustomizationSidebarListStyle,
|
||||||
@@ -86,11 +85,6 @@ export async function CustomizationRootLayout(props: {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
lang={customization.internationalization.locale}
|
lang={customization.internationalization.locale}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
/* This offset is used when scrolling to elements on the page, like #anchors and (on mobile) the latest AI chat message.
|
|
||||||
* It is set to the maximum height of the header (64px) + sections (44px) + offset (40px) */
|
|
||||||
customization.header.preset === CustomizationHeaderPreset.None
|
|
||||||
? null
|
|
||||||
: 'scroll-pt-[148px]',
|
|
||||||
customization.styling.corners && `${customization.styling.corners}-corners`,
|
customization.styling.corners && `${customization.styling.corners}-corners`,
|
||||||
'theme' in customization.styling && `theme-${customization.styling.theme}`,
|
'theme' in customization.styling && `theme-${customization.styling.theme}`,
|
||||||
tintColor ? ' tint' : 'no-tint',
|
tintColor ? ' tint' : 'no-tint',
|
||||||
|
|||||||
@@ -54,10 +54,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flip-heading-hash {
|
.flip-heading-hash {
|
||||||
@apply [&:is(h1,h2,h3,h4)]:content-start;
|
@apply [&:is(h1,h2,h3,h4)]:flex-row-reverse;
|
||||||
@apply [&:is(h1,h2,h3,h4)]:[grid-auto-columns:auto_1fr];
|
@apply [&:is(h1,h2,h3,h4)]:[justify-content:start];
|
||||||
@apply [&:is(h1,h2,h3,h4)>div:first-child]:[grid-area:1/2];
|
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-2;
|
||||||
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function HighlightQuery(props: {
|
|||||||
'py-0.5',
|
'py-0.5',
|
||||||
'rounded',
|
'rounded',
|
||||||
'straight-corners:rounded-sm',
|
'straight-corners:rounded-sm',
|
||||||
|
'transition-colors',
|
||||||
|
'group-hover:bg-primary-active',
|
||||||
|
'group-hover:text-contrast-primary-active',
|
||||||
'group-[.is-active]:bg-primary-active',
|
'group-[.is-active]:bg-primary-active',
|
||||||
'group-[.is-active]:text-contrast-primary-active',
|
'group-[.is-active]:text-contrast-primary-active',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { t } from '@/intl/translate';
|
|||||||
import type { TranslationLanguage } from '@/intl/translations';
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { AIResponseFeedback } from '../AIChat';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Link } from '../primitives';
|
import { Link } from '../primitives';
|
||||||
import { useSearchAskContext } from './SearchAskContext';
|
import { useSearchAskContext } from './SearchAskContext';
|
||||||
@@ -88,20 +89,26 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
}, [setAskState]);
|
}, [setAskState]);
|
||||||
|
|
||||||
const loading = (
|
const loading = (
|
||||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
<div className="flex grow items-center justify-center">
|
||||||
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
|
<Loading className={tcls('size-6', 'text-tint/6')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
|
<div className="flex min-h-full p-4">
|
||||||
{askState?.type === 'answer' ? (
|
{askState?.type === 'answer' ? (
|
||||||
<React.Suspense fallback={loading}>
|
<React.Suspense fallback={loading}>
|
||||||
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
|
<TransitionAnswerBody
|
||||||
|
answer={askState.answer}
|
||||||
|
placeholder={loading}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{askState?.type === 'error' ? (
|
{askState?.type === 'error' ? (
|
||||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
<div className={tcls('grow p-4 text-center')}>
|
||||||
|
{t(language, 'search_ask_error')}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{askState?.type === 'loading' ? loading : null}
|
{askState?.type === 'loading' ? loading : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,8 +119,12 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
* Since the answer can be an async component that could suspend rendering,
|
* Since the answer can be an async component that could suspend rendering,
|
||||||
* we need to wrap it in a transition to avoid flickering.
|
* we need to wrap it in a transition to avoid flickering.
|
||||||
*/
|
*/
|
||||||
function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) {
|
function TransitionAnswerBody(props: {
|
||||||
const { answer, placeholder } = props;
|
query: string;
|
||||||
|
answer: AskAnswerResult;
|
||||||
|
placeholder: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { query, answer, placeholder } = props;
|
||||||
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
|
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
|
||||||
const [_isPending, startTransition] = React.useTransition();
|
const [_isPending, startTransition] = React.useTransition();
|
||||||
|
|
||||||
@@ -125,24 +136,25 @@ function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: Rea
|
|||||||
|
|
||||||
return display ? (
|
return display ? (
|
||||||
<div className={tcls('w-full')}>
|
<div className={tcls('w-full')}>
|
||||||
<AnswerBody answer={display} />
|
<AnswerBody query={query} answer={display} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>{placeholder}</>
|
<>{placeholder}</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnswerBody(props: { answer: AskAnswerResult }) {
|
function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
|
||||||
const { answer } = props;
|
const { query, answer } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div data-testid="search-ask-answer" className="text-tint-strong">
|
||||||
data-testid="search-ask-answer"
|
|
||||||
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
|
|
||||||
>
|
|
||||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||||
|
{answer.sources.length > 0 ? (
|
||||||
|
// @TODO: Add responseId once search uses new AI endpoint
|
||||||
|
<AIResponseFeedback query={query} className="-ml-1 mt-2" responseId="" />
|
||||||
|
) : null}
|
||||||
{answer.followupQuestions.length > 0 ? (
|
{answer.followupQuestions.length > 0 ? (
|
||||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -163,7 +175,7 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
|||||||
const getSearchLinkProps = useSearchLink();
|
const getSearchLinkProps = useSearchLink();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4', 'sm:mt-6')}>
|
<div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4')}>
|
||||||
{followupQuestions.map((question) => (
|
{followupQuestions.map((question) => (
|
||||||
<Link
|
<Link
|
||||||
key={question}
|
key={question}
|
||||||
@@ -212,8 +224,7 @@ function AnswerSources(props: {
|
|||||||
'gap-2',
|
'gap-2',
|
||||||
'mt-4',
|
'mt-4',
|
||||||
'sm:mt-6',
|
'sm:mt-6',
|
||||||
'py-4',
|
'pt-4',
|
||||||
'px-4',
|
|
||||||
'border-t',
|
'border-t',
|
||||||
'border-subtle'
|
'border-subtle'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
|
||||||
import { useTrackEvent } from '../Insights';
|
|
||||||
import { useSearch } from './useSearch';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button to open the search modal.
|
|
||||||
*/
|
|
||||||
export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) {
|
|
||||||
const { style, children } = props;
|
|
||||||
|
|
||||||
const language = useLanguage();
|
|
||||||
const [, setSearchState] = useSearch();
|
|
||||||
const trackEvent = useTrackEvent();
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
setSearchState({
|
|
||||||
ask: false,
|
|
||||||
global: false,
|
|
||||||
query: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
trackEvent({
|
|
||||||
type: 'search_open',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={tString(language, 'search')}
|
|
||||||
className={tcls(
|
|
||||||
'flex',
|
|
||||||
'flex-1',
|
|
||||||
'flex-row',
|
|
||||||
'justify-center',
|
|
||||||
'items-center',
|
|
||||||
'w-full',
|
|
||||||
'py-2.5',
|
|
||||||
'md:py-2',
|
|
||||||
'px-3',
|
|
||||||
'circular-corners:px-4',
|
|
||||||
'gap-2',
|
|
||||||
|
|
||||||
'bg-tint-base',
|
|
||||||
|
|
||||||
'ring-1',
|
|
||||||
'ring-tint-12/2',
|
|
||||||
'depth-flat:ring-tint-subtle',
|
|
||||||
|
|
||||||
'shadow-sm',
|
|
||||||
'shadow-tint-12/3',
|
|
||||||
'dark:shadow-none',
|
|
||||||
'depth-flat:shadow-none',
|
|
||||||
|
|
||||||
'text-tint',
|
|
||||||
|
|
||||||
'rounded-lg',
|
|
||||||
'straight-corners:rounded-sm',
|
|
||||||
'circular-corners:rounded-full',
|
|
||||||
|
|
||||||
'contrast-more:ring-tint-12',
|
|
||||||
'contrast-more:text-tint-strong',
|
|
||||||
|
|
||||||
'transition-all',
|
|
||||||
'hover:bg-tint-subtle',
|
|
||||||
'hover:shadow-md',
|
|
||||||
'hover:scale-102',
|
|
||||||
'depth-flat:hover:scale-100',
|
|
||||||
'hover:ring-tint-hover',
|
|
||||||
'hover:text-tint-strong',
|
|
||||||
'focus:shadow-md',
|
|
||||||
'focus:scale-102',
|
|
||||||
'depth-flat:focus:scale-100',
|
|
||||||
'focus:ring-tint-hover',
|
|
||||||
'focus:text-tint-strong',
|
|
||||||
|
|
||||||
'contrast-more:hover:ring-2',
|
|
||||||
'contrast-more:hover:ring-tint-12',
|
|
||||||
'contrast-more:focus:ring-2',
|
|
||||||
'contrast-more:focus:ring-tint-12',
|
|
||||||
|
|
||||||
'active:shadow-sm',
|
|
||||||
'active:scale-100',
|
|
||||||
|
|
||||||
'md:justify-start',
|
|
||||||
'md:w-full',
|
|
||||||
'text-sm',
|
|
||||||
style
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="magnifying-glass"
|
|
||||||
className={tcls('text-tint-subtle', 'shrink-0', 'size-4')}
|
|
||||||
/>
|
|
||||||
<div className={tcls('w-full', 'hidden', 'md:block', 'text-left')}>{children}</div>
|
|
||||||
<Shortcut />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Shortcut() {
|
|
||||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function getOperatingSystem() {
|
|
||||||
const platform = navigator.platform.toLowerCase();
|
|
||||||
|
|
||||||
if (platform.includes('mac')) return 'mac';
|
|
||||||
if (platform.includes('win')) return 'win';
|
|
||||||
|
|
||||||
return 'win';
|
|
||||||
}
|
|
||||||
|
|
||||||
setOperatingSystem(getOperatingSystem());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
|
||||||
className={tcls(
|
|
||||||
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`,
|
|
||||||
operatingSystem
|
|
||||||
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
|
|
||||||
: 'opacity-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<kbd
|
|
||||||
className={`flex h-5 min-w-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
|
||||||
>
|
|
||||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
|
||||||
</kbd>
|
|
||||||
<kbd className="flex size-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background">
|
|
||||||
K
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import { Popover } from '../primitives';
|
||||||
|
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||||
|
import { useSearchAskState } from './SearchAskContext';
|
||||||
|
import { SearchAskProvider } from './SearchAskContext';
|
||||||
|
import { SearchInput } from './SearchInput';
|
||||||
|
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||||
|
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||||
|
import { useSearch } from './useSearch';
|
||||||
|
|
||||||
|
interface SearchContainerProps {
|
||||||
|
spaceTitle: string;
|
||||||
|
isMultiVariants: boolean;
|
||||||
|
aiMode: CustomizationAIMode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client component to render the search input and results.
|
||||||
|
*/
|
||||||
|
export function SearchContainer(props: SearchContainerProps) {
|
||||||
|
const { spaceTitle, isMultiVariants, aiMode, className } = props;
|
||||||
|
|
||||||
|
const [state, setSearchState] = useSearch();
|
||||||
|
const searchAsk = useSearchAskState();
|
||||||
|
const router = useRouter();
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
const resultsRef = useRef<SearchResultsRef>(null);
|
||||||
|
const searchInputRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// Derive open state from search state
|
||||||
|
const open = state?.open ?? false;
|
||||||
|
|
||||||
|
const onClose = async (to?: string) => {
|
||||||
|
if (state?.query === '') {
|
||||||
|
await setSearchState(null);
|
||||||
|
} else if (state) {
|
||||||
|
await setSearchState({ ...state, open: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
router.push(to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'mod+k',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpen = () => {
|
||||||
|
if (open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: prev?.ask ?? false,
|
||||||
|
global: prev?.global ?? false,
|
||||||
|
query: prev?.query ?? '',
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
trackEvent({
|
||||||
|
type: 'search_open',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
resultsRef.current?.moveUp();
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
resultsRef.current?.moveDown();
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
resultsRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string) => {
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: false, // When typing, we go back to the default search mode
|
||||||
|
query: value,
|
||||||
|
global: prev?.global ?? false,
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// We trim the query to avoid invalidating the search when the user is typing between words.
|
||||||
|
const normalizedQuery = state?.query.trim() ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchAskProvider value={searchAsk}>
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
// Only show content if there's a query or Ask is enabled
|
||||||
|
(state?.query || aiMode !== CustomizationAIMode.None) && open ? (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
{isMultiVariants && !state?.ask ? (
|
||||||
|
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||||
|
) : null}
|
||||||
|
{state !== null && !state.ask ? (
|
||||||
|
<SearchResults
|
||||||
|
ref={resultsRef}
|
||||||
|
query={normalizedQuery}
|
||||||
|
global={state?.global ?? false}
|
||||||
|
aiMode={aiMode}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{state?.ask ? <SearchAskAnswer query={normalizedQuery} /> : null}
|
||||||
|
</React.Suspense>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
rootProps={{
|
||||||
|
open: open,
|
||||||
|
modal: isMobile,
|
||||||
|
}}
|
||||||
|
contentProps={{
|
||||||
|
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||||
|
align: 'start',
|
||||||
|
className:
|
||||||
|
'bg-tint-base has-[.empty]:hidden scroll-py-2 w-[32rem] 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)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
sideOffset: 8,
|
||||||
|
collisionPadding: {
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 32,
|
||||||
|
left: 16,
|
||||||
|
},
|
||||||
|
hideWhenDetached: true,
|
||||||
|
}}
|
||||||
|
triggerProps={{
|
||||||
|
asChild: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchInput
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={state?.query ?? ''}
|
||||||
|
onFocus={onOpen}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
aiMode={aiMode}
|
||||||
|
isOpen={open}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</SearchAskProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { Button, variantClasses } from '../primitives';
|
||||||
|
import { useClassnames } from '../primitives/StyleProvider';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
value: string;
|
||||||
|
aiMode: CustomizationAIMode;
|
||||||
|
isOpen: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size classes for medium size button
|
||||||
|
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input to trigger search.
|
||||||
|
*/
|
||||||
|
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||||
|
function SearchInput(props, ref) {
|
||||||
|
const { onChange, onKeyDown, onFocus, value, aiMode, isOpen, className } = props;
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
const buttonStyles = useClassnames(['ButtonStyles']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (document.activeElement !== inputRef.current) {
|
||||||
|
// Refocus the input and move the caret to the end – do this only once to avoid scroll jumps on every keystroke
|
||||||
|
inputRef.current?.focus({ preventScroll: true });
|
||||||
|
// Place cursor at the end of the input
|
||||||
|
inputRef.current?.setSelectionRange(value.length, value.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}, [isOpen, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex size-9 grow">
|
||||||
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: this div needs an onClick to show the input on mobile, where it's normally hidden.
|
||||||
|
Normally you'd also need to add a keyboard trigger to do the same without a pointer, but in this case the input already be focused on its own. */}
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onClick={onFocus}
|
||||||
|
className={tcls(
|
||||||
|
// Apply button styles
|
||||||
|
buttonStyles,
|
||||||
|
variantClasses.header,
|
||||||
|
sizeClasses,
|
||||||
|
// Additional custom styles
|
||||||
|
'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base depth-subtle:has-[input:focus]:shadow-lg depth-subtle:has-[input:focus]:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text',
|
||||||
|
'theme-bold:border-header-link/3 theme-bold:has-[input:focus-visible]:border-header-link/5 theme-bold:has-[input:focus-visible]:bg-header-link/3 theme-bold:has-[input:focus-visible]:ring-header-link/5',
|
||||||
|
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
|
||||||
|
'relative z-30 shrink grow justify-start max-md:absolute max-md:right-0',
|
||||||
|
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value && isOpen ? (
|
||||||
|
<Button
|
||||||
|
variant="blank"
|
||||||
|
label="Clear"
|
||||||
|
size="medium"
|
||||||
|
iconOnly
|
||||||
|
icon="circle-xmark"
|
||||||
|
className="-ml-1.5 -mr-1 animate-scaleIn px-1.5"
|
||||||
|
onClick={() => {
|
||||||
|
onChange('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon="magnifying-glass" className="size-4 shrink-0 animate-scaleIn" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
onFocus={onFocus}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
value={value}
|
||||||
|
// We only show "search or ask" if the search input actually handles both search and ask.
|
||||||
|
placeholder={`${tString(language, aiMode === CustomizationAIMode.Search ? 'search_or_ask' : 'search')}...`}
|
||||||
|
maxLength={512}
|
||||||
|
size={10}
|
||||||
|
className={tcls(
|
||||||
|
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-none transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
|
||||||
|
isOpen ? '' : 'max-md:opacity-0'
|
||||||
|
)}
|
||||||
|
ref={inputRef}
|
||||||
|
/>
|
||||||
|
{!isOpen ? <Shortcut /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function getOperatingSystem() {
|
||||||
|
const platform = navigator.platform.toLowerCase();
|
||||||
|
|
||||||
|
if (platform.includes('mac')) return 'mac';
|
||||||
|
if (platform.includes('win')) return 'win';
|
||||||
|
|
||||||
|
return 'win';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shortcut() {
|
||||||
|
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOperatingSystem(getOperatingSystem());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||||
|
className={tcls(
|
||||||
|
`shortcut -mr-1 relative z-10 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt",_"case"] after:absolute after:right-full after:z-20 after:h-full after:w-8 after:bg-gradient-to-r after:from-transparent after:to-tint-base theme-bold:after:to-transparent after:content-[''] contrast-more:text-tint-strong md:flex`,
|
||||||
|
operatingSystem
|
||||||
|
? 'motion-safe:animate-fadeIn motion-reduce:opacity-11'
|
||||||
|
: 'opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<kbd
|
||||||
|
className={`flex h-5 min-w-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
||||||
|
>
|
||||||
|
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
||||||
|
</kbd>
|
||||||
|
<kbd className="flex size-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1">
|
||||||
|
K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import React from 'react';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
import { LoadingPane } from '../primitives/LoadingPane';
|
|
||||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
|
||||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
|
||||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
|
||||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
|
||||||
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
|
|
||||||
|
|
||||||
interface SearchModalProps {
|
|
||||||
spaceTitle: string;
|
|
||||||
isMultiVariants: boolean;
|
|
||||||
withAsk: boolean;
|
|
||||||
withAIChat: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client component to render the search modal when the url contains a search query.
|
|
||||||
*/
|
|
||||||
export function SearchModal(props: SearchModalProps) {
|
|
||||||
const [state, setSearchState] = useSearch();
|
|
||||||
const searchAsk = useSearchAskState();
|
|
||||||
const [askState] = searchAsk;
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
'mod+k',
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSearchState({ ask: false, query: '', global: false });
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a global class on the body when the search modal is open
|
|
||||||
const isSearchOpened = state !== null;
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isSearchOpened) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = 'auto';
|
|
||||||
};
|
|
||||||
}, [isSearchOpened]);
|
|
||||||
|
|
||||||
const onClose = async (to?: string) => {
|
|
||||||
await setSearchState(null);
|
|
||||||
if (to) {
|
|
||||||
router.push(to);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SearchAskProvider value={searchAsk}>
|
|
||||||
<AnimatePresence>
|
|
||||||
{state !== null ? (
|
|
||||||
<motion.div
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 0.2,
|
|
||||||
delay: 0.1,
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
className={tcls(
|
|
||||||
'fixed',
|
|
||||||
'inset-0',
|
|
||||||
'bg-tint-12/4',
|
|
||||||
'dark:bg-tint-1/6',
|
|
||||||
'backdrop-blur-2xl',
|
|
||||||
'z-50',
|
|
||||||
'px-4',
|
|
||||||
'pt-4',
|
|
||||||
'md:pt-[min(8vh,6rem)]'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="scroll-nojump">
|
|
||||||
<AnimatePresence>
|
|
||||||
{askState?.type === 'loading' ? (
|
|
||||||
<motion.div
|
|
||||||
key="loading"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 1 }}
|
|
||||||
className={tcls(
|
|
||||||
'w-screen',
|
|
||||||
'h-screen',
|
|
||||||
'fixed',
|
|
||||||
'inset-0',
|
|
||||||
'z-10',
|
|
||||||
'pointer-events-none'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LoadingPane
|
|
||||||
gridStyle={['h-screen', 'aspect-auto', 'top-[-30%]']}
|
|
||||||
pulse
|
|
||||||
tile={96}
|
|
||||||
style={['grid']}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
<SearchModalBody
|
|
||||||
{...props}
|
|
||||||
state={state}
|
|
||||||
setSearchState={setSearchState}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SearchAskProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchModalBody(
|
|
||||||
props: SearchModalProps & {
|
|
||||||
state: SearchState;
|
|
||||||
setSearchState: UpdateSearchState;
|
|
||||||
onClose: (to?: string) => void;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const { spaceTitle, withAsk, withAIChat, isMultiVariants, state, setSearchState, onClose } =
|
|
||||||
props;
|
|
||||||
|
|
||||||
const language = useLanguage();
|
|
||||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
event.preventDefault();
|
|
||||||
resultsRef.current?.moveUp();
|
|
||||||
} else if (event.key === 'ArrowDown') {
|
|
||||||
event.preventDefault();
|
|
||||||
resultsRef.current?.moveDown();
|
|
||||||
} else if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
resultsRef.current?.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchState({
|
|
||||||
ask: false, // When typing, we go back to the default search mode
|
|
||||||
query: event.target.value,
|
|
||||||
global: state.global,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSwitchToAsk = () => {
|
|
||||||
setSearchState((state) => (state ? { ...state, ask: true } : null));
|
|
||||||
};
|
|
||||||
|
|
||||||
// We trim the query to avoid invalidating the search when the user is typing between words.
|
|
||||||
const normalizedQuery = state.query.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
transition={{
|
|
||||||
duration: 0.2,
|
|
||||||
delay: 0.1,
|
|
||||||
ease: 'easeOut',
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
scale: 0.95,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
scale: 0.95,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
aria-label={tString(language, 'search')}
|
|
||||||
className={tcls(
|
|
||||||
'z-40',
|
|
||||||
'relative',
|
|
||||||
'flex',
|
|
||||||
'flex-col',
|
|
||||||
'bg-tint-base',
|
|
||||||
'max-w-prose',
|
|
||||||
'mx-auto',
|
|
||||||
'max-h-[70dvh]',
|
|
||||||
'w-full',
|
|
||||||
'rounded-lg',
|
|
||||||
'straight-corners:rounded-sm',
|
|
||||||
'circular-corners:rounded-2xl',
|
|
||||||
'ring-1',
|
|
||||||
'ring-tint',
|
|
||||||
'shadow-2xl',
|
|
||||||
'depth-flat:shadow-none',
|
|
||||||
'overflow-hidden',
|
|
||||||
'dark:ring-inset',
|
|
||||||
'dark:ring-tint'
|
|
||||||
)}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'flex',
|
|
||||||
'flex-row',
|
|
||||||
'items-start',
|
|
||||||
state.query !== null ? 'border-b' : null,
|
|
||||||
'border-tint-subtle'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={tcls('p-2', 'pl-4', 'pt-4')}>
|
|
||||||
<Icon icon="magnifying-glass" className={tcls('size-4', 'text-tint-subtle')} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'w-full',
|
|
||||||
'flex',
|
|
||||||
'flex-row',
|
|
||||||
'flex-wrap',
|
|
||||||
'gap-y-0',
|
|
||||||
'gap-x-4',
|
|
||||||
'items-end'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={state.query}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onChange={onChange}
|
|
||||||
className={tcls(
|
|
||||||
'text-tint-strong',
|
|
||||||
'placeholder:text-tint',
|
|
||||||
'flex',
|
|
||||||
'resize-none',
|
|
||||||
'flex-1',
|
|
||||||
'h-12',
|
|
||||||
'p-2',
|
|
||||||
'focus:outline-none',
|
|
||||||
'bg-transparent',
|
|
||||||
'whitespace-pre-line'
|
|
||||||
)}
|
|
||||||
placeholder={tString(
|
|
||||||
language,
|
|
||||||
withAsk ? 'search_ask_input_placeholder' : 'search_input_placeholder'
|
|
||||||
)}
|
|
||||||
spellCheck="false"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
/>
|
|
||||||
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!state.ask || !withAsk ? (
|
|
||||||
<SearchResults
|
|
||||||
ref={resultsRef}
|
|
||||||
global={isMultiVariants && state.global}
|
|
||||||
query={normalizedQuery}
|
|
||||||
withAsk={withAsk}
|
|
||||||
withAIChat={withAIChat}
|
|
||||||
onSwitchToAsk={onSwitchToAsk}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{normalizedQuery && state.ask && withAsk ? (
|
|
||||||
<SearchAskAnswer query={normalizedQuery} />
|
|
||||||
) : null}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
|
||||||
import { HighlightQuery } from './HighlightQuery';
|
import { HighlightQuery } from './HighlightQuery';
|
||||||
|
import { SearchResultItem } from './SearchResultItem';
|
||||||
import type { ComputedPageResult } from './server-actions';
|
import type { ComputedPageResult } from './server-actions';
|
||||||
|
|
||||||
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
|
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
|
||||||
@@ -15,6 +15,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
const { query, item, active } = props;
|
const { query, item, active } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
const breadcrumbs =
|
const breadcrumbs =
|
||||||
item.breadcrumbs?.map((crumb) => (
|
item.breadcrumbs?.map((crumb) => (
|
||||||
@@ -25,26 +26,12 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
)) ?? [];
|
)) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<SearchResultItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={tcls(
|
active={active}
|
||||||
'flex',
|
action={tString(language, 'view')}
|
||||||
'gap-4',
|
leadingIcon={<Icon icon="memo" className="size-4" />}
|
||||||
'flex-row',
|
|
||||||
'items-center',
|
|
||||||
'p-4',
|
|
||||||
'border-t',
|
|
||||||
'border-tint-subtle',
|
|
||||||
'first:border-none',
|
|
||||||
'text-base',
|
|
||||||
'font-medium',
|
|
||||||
'hover:bg-tint-hover',
|
|
||||||
'group',
|
|
||||||
active
|
|
||||||
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
insights={{
|
insights={{
|
||||||
type: 'search_open_result',
|
type: 'search_open_result',
|
||||||
query,
|
query,
|
||||||
@@ -54,21 +41,17 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="size-4">
|
|
||||||
<Icon
|
|
||||||
icon="file-lines"
|
|
||||||
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
|
||||||
{breadcrumbs.length > 0 ? (
|
{breadcrumbs.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
'opacity-6',
|
'text-tint/7',
|
||||||
'contrast-more:opacity-11',
|
'contrast-more:text-tint',
|
||||||
|
'group-[.is-active]:text-tint',
|
||||||
|
'transition-colors',
|
||||||
'font-normal',
|
'font-normal',
|
||||||
'uppercase',
|
'uppercase',
|
||||||
|
'leading-none',
|
||||||
'tracking-wider',
|
'tracking-wider',
|
||||||
'mb-1',
|
'mb-1',
|
||||||
'flex',
|
'flex',
|
||||||
@@ -86,37 +69,22 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
]
|
]
|
||||||
: breadcrumbs
|
: breadcrumbs
|
||||||
).map((crumb, index) => (
|
).map((crumb, index) => (
|
||||||
<>
|
<React.Fragment key={index}>
|
||||||
{index !== 0 ? (
|
{index !== 0 ? (
|
||||||
<Icon
|
<Icon
|
||||||
key={`${crumb.key}-icon`}
|
key={`${crumb.key}-icon`}
|
||||||
icon="chevron-right"
|
icon="chevron-right"
|
||||||
className="size-3"
|
className="size-2"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span key={crumb.key} className="line-clamp-1">
|
<span className="line-clamp-1">{crumb}</span>
|
||||||
{crumb}
|
</React.Fragment>
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
|
||||||
<HighlightQuery query={query} text={item.title} />
|
<HighlightQuery query={query} text={item.title} />
|
||||||
</div>
|
</p>
|
||||||
<div
|
</SearchResultItem>
|
||||||
className={tcls(
|
|
||||||
'p-2',
|
|
||||||
'rounded',
|
|
||||||
'straight-corners:rounded-none',
|
|
||||||
'circular-corners:rounded-full',
|
|
||||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
|
||||||
className={tcls('size-4')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,101 +1,73 @@
|
|||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { Icon } from '@gitbook/icons';
|
||||||
|
|
||||||
import { useAIChatController } from '../AI';
|
import { useAIChatController } from '../AI';
|
||||||
import { Link } from '../primitives';
|
import { AIChatIcon } from '../AIChat/';
|
||||||
|
import { SearchResultItem } from './SearchResultItem';
|
||||||
import { useSearch, useSearchLink } from './useSearch';
|
import { useSearch, useSearchLink } from './useSearch';
|
||||||
|
|
||||||
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
||||||
props: {
|
props: {
|
||||||
question: string;
|
question: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
|
||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
withAIChat: boolean;
|
withAIChat: boolean;
|
||||||
},
|
},
|
||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
const { question, recommended = false, active, onClick, withAIChat } = props;
|
const { question, recommended = false, active, withAIChat } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const getLinkProp = useSearchLink();
|
const getLinkProp = useSearchLink();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
const [, setSearchState] = useSearch();
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<SearchResultItem
|
||||||
|
size={recommended ? 'small' : 'medium'}
|
||||||
|
action={tString(language, 'ask', '')}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-testid="search-result-item"
|
||||||
|
scroll={false}
|
||||||
|
{...(withAIChat
|
||||||
|
? { href: '#' }
|
||||||
|
: getLinkProp({
|
||||||
|
ask: true,
|
||||||
|
open: true,
|
||||||
|
query: question,
|
||||||
|
}))}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (withAIChat) {
|
if (withAIChat) {
|
||||||
// If AI Chat is enabled, hijack to open the chat and post the question
|
// If AI Chat is enabled, hijack to open the chat and post the question
|
||||||
chatController.open();
|
chatController.open();
|
||||||
chatController.postMessage({ message: question });
|
chatController.postMessage({ message: question });
|
||||||
setSearchState(null); // Close the search modal
|
setSearchState(null); // Close the search modal
|
||||||
} else {
|
|
||||||
onClick();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-testid="search-result-item"
|
active={active}
|
||||||
className={tcls(
|
leadingIcon={
|
||||||
'flex',
|
recommended ? (
|
||||||
'px-4',
|
<Icon icon="search" className="size-4" />
|
||||||
recommended ? ['py-2', 'text-tint'] : 'py-4',
|
) : withAIChat ? (
|
||||||
'hover:bg-tint-hover',
|
<AIChatIcon className="size-4" />
|
||||||
'first:mt-0',
|
) : (
|
||||||
'last:pb-3',
|
<Icon icon="sparkles" className="size-4" />
|
||||||
active && [
|
)
|
||||||
'is-active',
|
}
|
||||||
'bg-primary',
|
className={recommended ? 'pr-1.5' : ''}
|
||||||
'text-contrast-primary',
|
|
||||||
'hover:bg-primary-hover',
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
{...(withAIChat
|
|
||||||
? { href: '#' }
|
|
||||||
: getLinkProp({
|
|
||||||
ask: true,
|
|
||||||
query: question,
|
|
||||||
}))}
|
|
||||||
>
|
>
|
||||||
<Icon
|
|
||||||
icon={recommended ? 'search' : 'sparkles'}
|
|
||||||
className={tcls(
|
|
||||||
'size-4',
|
|
||||||
'shrink-0',
|
|
||||||
'mt-1.5',
|
|
||||||
'mr-4',
|
|
||||||
active ? ['text-primary'] : ['text-tint-subtle']
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="w-full">
|
|
||||||
{recommended ? (
|
{recommended ? (
|
||||||
question
|
question
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium">{t(language, 'search_ask', [question])}</div>
|
<div className="font-semibold text-base text-tint-strong leading-tight">
|
||||||
<div className={tcls('text-sm', 'text-tint')}>
|
{t(language, 'search_ask', [question])}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-tint-subtle">
|
||||||
{t(language, 'search_ask_description')}
|
{t(language, 'search_ask_description')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SearchResultItem>
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'p-2',
|
|
||||||
'rounded',
|
|
||||||
'self-center',
|
|
||||||
'straight-corners:rounded-none',
|
|
||||||
'circular-corners:rounded-full',
|
|
||||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
|
||||||
className={tcls('size-4')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
import { Link, type LinkInsightsProps } from '../primitives/Link';
|
||||||
|
|
||||||
|
export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
||||||
|
props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
action: string;
|
||||||
|
active: boolean;
|
||||||
|
className?: string;
|
||||||
|
size?: 'small' | 'medium';
|
||||||
|
leadingIcon?: React.ReactNode;
|
||||||
|
} & LinkInsightsProps &
|
||||||
|
Omit<React.ComponentProps<typeof Link>, keyof LinkInsightsProps>,
|
||||||
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
className,
|
||||||
|
leadingIcon,
|
||||||
|
size = 'medium',
|
||||||
|
action,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={ref}
|
||||||
|
href={href}
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'gap-3',
|
||||||
|
'group',
|
||||||
|
'px-4',
|
||||||
|
size === 'small' ? 'py-1.5' : 'py-3',
|
||||||
|
'text-tint',
|
||||||
|
'hover:bg-tint',
|
||||||
|
'hover:text-tint-strong',
|
||||||
|
'group',
|
||||||
|
'transition-colors',
|
||||||
|
'rounded-corners:rounded-md',
|
||||||
|
'circular-corners:rounded-2xl',
|
||||||
|
active
|
||||||
|
? ['is-active', 'bg-primary', 'text-primary-strong', 'hover:bg-primary']
|
||||||
|
: null,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<div className="size-4 shrink-0 text-tint-subtle">{leadingIcon}</div>
|
||||||
|
<div className="grow">{children}</div>
|
||||||
|
{active ? (
|
||||||
|
<Button
|
||||||
|
label={action}
|
||||||
|
iconOnly
|
||||||
|
variant="primary"
|
||||||
|
className="p-2"
|
||||||
|
icon="arrow-turn-down-left"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center text-tint-subtle">
|
||||||
|
<Icon icon="chevron-right" className="size-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import React from 'react';
|
|||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Loading } from '../primitives';
|
import { Loading } from '../primitives';
|
||||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||||
@@ -40,20 +41,18 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
|
|||||||
* Fetch the results of the keyboard navigable elements to display for a query:
|
* Fetch the results of the keyboard navigable elements to display for a query:
|
||||||
* - Recommended questions if no query is provided.
|
* - Recommended questions if no query is provided.
|
||||||
* - Search results if a query is provided.
|
* - Search results if a query is provided.
|
||||||
* - If withAsk is true, add a question result.
|
* - If withAI is true, add a question result.
|
||||||
*/
|
*/
|
||||||
export const SearchResults = React.forwardRef(function SearchResults(
|
export const SearchResults = React.forwardRef(function SearchResults(
|
||||||
props: {
|
props: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
query: string;
|
query: string;
|
||||||
global: boolean;
|
global: boolean;
|
||||||
withAsk: boolean;
|
aiMode: CustomizationAIMode;
|
||||||
withAIChat: boolean;
|
|
||||||
onSwitchToAsk: () => void;
|
|
||||||
},
|
},
|
||||||
ref: React.Ref<SearchResultsRef>
|
ref: React.Ref<SearchResultsRef>
|
||||||
) {
|
) {
|
||||||
const { children, query, withAsk, withAIChat, global, onSwitchToAsk } = props;
|
const { children, query, aiMode, global } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
@@ -64,9 +63,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||||
|
|
||||||
|
const withAI =
|
||||||
|
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
if (!withAsk) {
|
if (!withAI) {
|
||||||
setResultsState({ results: [], fetching: false });
|
setResultsState({ results: [], fetching: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,11 +78,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
// Silently fetch the recommended questions, instead of showing a spinner
|
|
||||||
setResultsState({ results: [], fetching: false });
|
setResultsState({ results: [], fetching: false });
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
// We currently have a bug where the same question can be returned multiple times.
|
// We currently have a bug where the same question can be returned multiple times.
|
||||||
// This is a workaround to avoid that.
|
// This is a workaround to avoid that.
|
||||||
const questions = new Set<string>();
|
const questions = new Set<string>();
|
||||||
@@ -149,14 +150,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [query, global, withAsk, trackEvent]);
|
}, [query, global, withAI, trackEvent]);
|
||||||
|
|
||||||
const results: ResultType[] = React.useMemo(() => {
|
const results: ResultType[] = React.useMemo(() => {
|
||||||
if (!withAsk) {
|
if (!withAI) {
|
||||||
return resultsState.results;
|
return resultsState.results;
|
||||||
}
|
}
|
||||||
return withQuestionResult(resultsState.results, query);
|
return withQuestionResult(resultsState.results, query);
|
||||||
}, [resultsState.results, query, withAsk]);
|
}, [resultsState.results, query, withAI]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -217,28 +218,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
|
|
||||||
if (resultsState.fetching) {
|
if (resultsState.fetching) {
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8', 'h-full')}>
|
||||||
<Loading className={tcls('w-6', 'text-primary')} />
|
<Loading className={tcls('w-6', 'text-tint/6')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noResults = (
|
const noResults = (
|
||||||
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'text-center',
|
||||||
|
'py-8',
|
||||||
|
'h-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t(language, 'search_no_results_for', query)}
|
{t(language, 'search_no_results_for', query)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls('overflow-auto')}>
|
<div className={tcls('min-h-full')}>
|
||||||
{children}
|
{children}
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
query ? (
|
query ? (
|
||||||
noResults
|
noResults
|
||||||
) : null
|
) : (
|
||||||
|
<div className="empty" />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div data-testid="search-results">
|
<div data-testid="search-results" className="flex flex-col gap-y-1">
|
||||||
{results.map((item, index) => {
|
{results.map((item, index) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'page': {
|
case 'page': {
|
||||||
@@ -260,11 +272,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
refs.current[index] = ref;
|
refs.current[index] = ref;
|
||||||
}}
|
}}
|
||||||
withAIChat={withAIChat}
|
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
question={query}
|
question={query}
|
||||||
active={index === cursor}
|
active={index === cursor}
|
||||||
onClick={onSwitchToAsk}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,10 +286,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
refs.current[index] = ref;
|
refs.current[index] = ref;
|
||||||
}}
|
}}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
withAIChat={withAIChat}
|
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
||||||
question={item.question}
|
question={item.question}
|
||||||
active={index === cursor}
|
active={index === cursor}
|
||||||
onClick={onSwitchToAsk}
|
|
||||||
recommended
|
recommended
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { t, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
import { useSearch } from './useSearch';
|
import { useSearch } from './useSearch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,54 +19,34 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
|
|||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
className={tcls('flex', 'flex-row', 'mr-4')}
|
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<Button
|
||||||
|
variant="blank"
|
||||||
|
size="medium"
|
||||||
|
className="shrink grow justify-center whitespace-normal"
|
||||||
active={!state.global}
|
active={!state.global}
|
||||||
|
label={tString(language, 'search_scope_space', spaceTitle)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchState({
|
setSearchState({
|
||||||
...state,
|
...state,
|
||||||
global: false,
|
global: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{t(language, 'search_scope_space', spaceTitle)}
|
<Button
|
||||||
</ToggleButton>
|
variant="blank"
|
||||||
<ToggleButton
|
size="medium"
|
||||||
|
className="shrink grow justify-center whitespace-normal"
|
||||||
active={state.global}
|
active={state.global}
|
||||||
|
label={tString(language, 'search_scope_all')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchState({
|
setSearchState({
|
||||||
...state,
|
...state,
|
||||||
global: true,
|
global: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{t(language, 'search_scope_all')}
|
|
||||||
</ToggleButton>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleButton(props: { onClick: () => void; children: React.ReactNode; active: boolean }) {
|
|
||||||
const { onClick, children, active } = props;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
aria-selected={active}
|
|
||||||
onClick={onClick}
|
|
||||||
className={tcls(
|
|
||||||
'text-sm',
|
|
||||||
'transition-colors',
|
|
||||||
'px-3',
|
|
||||||
'py-2',
|
|
||||||
'border-b-2',
|
|
||||||
'whitespace-pre',
|
|
||||||
'-mb-[1px]',
|
|
||||||
active ? ['text-primary', 'border-primary'] : ['border-transparent']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
|
||||||
import { HighlightQuery } from './HighlightQuery';
|
import { HighlightQuery } from './HighlightQuery';
|
||||||
|
import { SearchResultItem } from './SearchResultItem';
|
||||||
import type { ComputedSectionResult } from './server-actions';
|
import type { ComputedSectionResult } from './server-actions';
|
||||||
|
|
||||||
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
|
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
|
||||||
@@ -16,30 +14,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
const { query, item, active } = props;
|
const { query, item, active } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<SearchResultItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={tcls(
|
size="small"
|
||||||
'[&:has(+:not(&))]:mb-6',
|
active={active}
|
||||||
'flex',
|
action={tString(language, 'view')}
|
||||||
'items-center',
|
|
||||||
'pl-6',
|
|
||||||
'sm:pl-12',
|
|
||||||
'pr-4',
|
|
||||||
'text-tint',
|
|
||||||
'hover:bg-tint-hover',
|
|
||||||
'font-normal',
|
|
||||||
'py-2',
|
|
||||||
'group',
|
|
||||||
active && [
|
|
||||||
'is-active',
|
|
||||||
'bg-primary',
|
|
||||||
'text-contrast-primary',
|
|
||||||
'hover:bg-primary-hover',
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
insights={{
|
insights={{
|
||||||
type: 'search_open_result',
|
type: 'search_open_result',
|
||||||
query,
|
query,
|
||||||
@@ -49,41 +32,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="grow border-tint-subtle border-l-2 pl-4">
|
||||||
className={tcls(
|
|
||||||
'border-l-2',
|
|
||||||
'px-4',
|
|
||||||
'py-1',
|
|
||||||
'flex',
|
|
||||||
'flex-1',
|
|
||||||
'overflow-hidden',
|
|
||||||
'flex-col',
|
|
||||||
'border-tint-subtle'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title ? (
|
{item.title ? (
|
||||||
<p className={tcls('text-base', 'mb-2')}>
|
<p className="font-semibold">
|
||||||
<HighlightQuery query={query} text={item.title} />
|
<HighlightQuery query={query} text={item.title} />
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{item.body ? highlightQueryInBody(item.body, query) : null}
|
{item.body ? highlightQueryInBody(item.body, query) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
</SearchResultItem>
|
||||||
className={tcls(
|
|
||||||
'p-2',
|
|
||||||
'rounded',
|
|
||||||
'straight-corners:rounded-none',
|
|
||||||
'circular-corners:rounded-full',
|
|
||||||
'bg-primary-solid',
|
|
||||||
'text-contrast-primary-solid',
|
|
||||||
'hidden',
|
|
||||||
'sm:block',
|
|
||||||
active ? ['opacity-11', 'block'] : ['opacity-0']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +49,7 @@ function highlightQueryInBody(body: string, query: string) {
|
|||||||
|
|
||||||
// Ensure the query to be highlighted is visible in the body.
|
// Ensure the query to be highlighted is visible in the body.
|
||||||
return (
|
return (
|
||||||
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
|
<p className="relative line-clamp-3 text-sm [overflow-wrap:anywhere]">
|
||||||
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
|
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './SearchButton';
|
export * from './SearchInput';
|
||||||
export * from './SearchModal';
|
export * from './SearchContainer';
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import React from 'react';
|
|||||||
import type { LinkProps } from '../primitives';
|
import type { LinkProps } from '../primitives';
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
|
// URL-backed state
|
||||||
query: string;
|
query: string;
|
||||||
ask: boolean;
|
ask: boolean;
|
||||||
global: boolean;
|
global: boolean;
|
||||||
|
|
||||||
|
// Local UI state
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
||||||
@@ -28,13 +32,24 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
|||||||
history: 'replace',
|
history: 'replace',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Separate local state for open (not synchronized with URL)
|
||||||
|
// Default to true if there's already a query in the URL
|
||||||
|
const [open, setIsOpen] = React.useState(() => {
|
||||||
|
return rawState?.q !== null;
|
||||||
|
});
|
||||||
|
|
||||||
const state = React.useMemo<SearchState | null>(() => {
|
const state = React.useMemo<SearchState | null>(() => {
|
||||||
if (rawState === null || rawState.q === null) {
|
if (rawState === null || rawState.q === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
|
return {
|
||||||
}, [rawState]);
|
query: rawState.q,
|
||||||
|
ask: !!rawState.ask,
|
||||||
|
global: !!rawState.global,
|
||||||
|
open: open,
|
||||||
|
};
|
||||||
|
}, [rawState, open]);
|
||||||
|
|
||||||
const stateRef = React.useRef(state);
|
const stateRef = React.useRef(state);
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
@@ -50,12 +65,17 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (update === null) {
|
if (update === null) {
|
||||||
|
setIsOpen(false);
|
||||||
return setRawState({
|
return setRawState({
|
||||||
q: null,
|
q: null,
|
||||||
ask: null,
|
ask: null,
|
||||||
global: null,
|
global: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the local state
|
||||||
|
setIsOpen(update.open);
|
||||||
|
|
||||||
return setRawState({
|
return setRawState({
|
||||||
q: update.query,
|
q: update.query,
|
||||||
ask: update.ask ? true : null,
|
ask: update.ask ? true : null,
|
||||||
@@ -78,8 +98,8 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
|||||||
(query) => {
|
(query) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.set('q', query.query ?? '');
|
searchParams.set('q', query.query ?? '');
|
||||||
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
|
query.ask ? searchParams.set('ask', 'true') : searchParams.delete('ask');
|
||||||
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
|
query.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||||
return {
|
return {
|
||||||
href: `?${searchParams.toString()}`,
|
href: `?${searchParams.toString()}`,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
@@ -89,6 +109,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
|||||||
query: '',
|
query: '',
|
||||||
ask: false,
|
ask: false,
|
||||||
global: false,
|
global: false,
|
||||||
|
open: true,
|
||||||
...(prev ?? {}),
|
...(prev ?? {}),
|
||||||
...query,
|
...query,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
|
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import { useClearRouterCache } from '../hooks/useClearRouterCache';
|
||||||
import { LinkSettingsContext } from '../primitives';
|
import { LinkSettingsContext } from '../primitives';
|
||||||
|
|
||||||
export function ClientContexts(props: {
|
export function ClientContexts(props: {
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
forcedTheme: CustomizationThemeMode | undefined;
|
forcedTheme: CustomizationThemeMode | undefined;
|
||||||
externalLinksTarget: SiteExternalLinksTarget;
|
externalLinksTarget: SiteExternalLinksTarget;
|
||||||
|
contextId: string | undefined;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { children, forcedTheme, externalLinksTarget } = props;
|
const { children, forcedTheme, externalLinksTarget, contextId } = props;
|
||||||
|
|
||||||
|
useClearRouterCache(contextId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bug in ThemeProvider is causing the nonce to be included incorrectly
|
* A bug in ThemeProvider is causing the nonce to be included incorrectly
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import { AdminToolbar } from '@/components/AdminToolbar';
|
|||||||
import { CookiesToast } from '@/components/Cookies';
|
import { CookiesToast } from '@/components/Cookies';
|
||||||
import { LoadIntegrations } from '@/components/Integrations';
|
import { LoadIntegrations } from '@/components/Integrations';
|
||||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||||
import { buildVersion } from '@/lib/build';
|
|
||||||
import { isSiteIndexable } from '@/lib/seo';
|
|
||||||
|
|
||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
|
import { buildVersion } from '@/lib/build';
|
||||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
|
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||||
import { getResizedImageURL } from '@/lib/images';
|
import { getResizedImageURL } from '@/lib/images';
|
||||||
|
import { isSiteIndexable } from '@/lib/seo';
|
||||||
import { ClientContexts } from './ClientContexts';
|
import { ClientContexts } from './ClientContexts';
|
||||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||||
|
|
||||||
@@ -50,6 +49,7 @@ export async function SiteLayout(props: {
|
|||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<ClientContexts
|
<ClientContexts
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
|
contextId={context.contextId}
|
||||||
forcedTheme={
|
forcedTheme={
|
||||||
forcedTheme ??
|
forcedTheme ??
|
||||||
(customization.themes.toggeable ? undefined : customization.themes.default)
|
(customization.themes.toggeable ? undefined : customization.themes.default)
|
||||||
@@ -95,6 +95,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
|
|||||||
? 'dark light'
|
? 'dark light'
|
||||||
: 'light dark'
|
: 'light dark'
|
||||||
: customization.themes.default,
|
: customization.themes.default,
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
|
|||||||
export function PageClientLayout(props: { withSections?: boolean }) {
|
export function PageClientLayout(props: { withSections?: boolean }) {
|
||||||
// We use this hook in the page layout to ensure the elements for the blocks
|
// We use this hook in the page layout to ensure the elements for the blocks
|
||||||
// are rendered before we scroll to a hash or to the top of the page
|
// are rendered before we scroll to a hash or to the top of the page
|
||||||
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
|
useScrollPage({ scrollMarginTop: props.withSections ? 108 : 64 });
|
||||||
|
|
||||||
useStripFallbackQueryParam();
|
useStripFallbackQueryParam();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function SitePageSkeleton() {
|
|||||||
'lg:items-start'
|
'lg:items-start'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-full-width:mx-0')}>
|
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-width-wide:mx-0')}>
|
||||||
<SkeletonHeading style={tcls('mb-8')} />
|
<SkeletonHeading style={tcls('mb-8')} />
|
||||||
<SkeletonParagraph style={tcls('mb-4')} />
|
<SkeletonParagraph style={tcls('mb-4')} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,14 +71,17 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
|
|||||||
redirectPathname,
|
redirectPathname,
|
||||||
]);
|
]);
|
||||||
for (const source of redirectSources) {
|
for (const source of redirectSources) {
|
||||||
const resolvedSiteRedirect = await getDataOrNull(
|
// We try to resolve the site redirect
|
||||||
|
const resolvedSiteRedirect =
|
||||||
|
source.length < 512 &&
|
||||||
|
(await getDataOrNull(
|
||||||
context.dataFetcher.getSiteRedirectBySource({
|
context.dataFetcher.getSiteRedirectBySource({
|
||||||
organizationId,
|
organizationId,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
source,
|
source,
|
||||||
siteShareKey: shareKey,
|
siteShareKey: shareKey,
|
||||||
})
|
})
|
||||||
);
|
));
|
||||||
if (resolvedSiteRedirect) {
|
if (resolvedSiteRedirect) {
|
||||||
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
|
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import React from 'react';
|
|||||||
|
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { Header, HeaderLogo } from '@/components/Header';
|
import { Header, HeaderLogo } from '@/components/Header';
|
||||||
import { SearchButton, SearchModal } from '@/components/Search';
|
|
||||||
import { TableOfContents } from '@/components/TableOfContents';
|
import { TableOfContents } from '@/components/TableOfContents';
|
||||||
import { CONTAINER_STYLE } from '@/components/layout';
|
import { CONTAINER_STYLE } from '@/components/layout';
|
||||||
import { getSpaceLanguage } from '@/intl/server';
|
|
||||||
import { t } from '@/intl/translate';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
|
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
|
||||||
import { AIChat } from '../AIChat';
|
import { AIChat } from '../AIChat';
|
||||||
|
import { AIChatButton } from '../AIChat';
|
||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider } from '../Insights';
|
import { InsightsProvider } from '../Insights';
|
||||||
|
import { SearchContainer } from '../Search';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { CurrentContentProvider } from '../hooks';
|
import { CurrentContentProvider } from '../hooks';
|
||||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||||
@@ -50,10 +49,22 @@ export function SpaceLayout(props: {
|
|||||||
customization.footer.logo ||
|
customization.footer.logo ||
|
||||||
customization.footer.groups?.length;
|
customization.footer.groups?.length;
|
||||||
|
|
||||||
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
|
const aiMode = customization.ai?.mode;
|
||||||
const withAIChat =
|
|
||||||
context.customization.ai?.mode === CustomizationAIMode.Assistant &&
|
const searchAndAI = (
|
||||||
(context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv');
|
<div className="flex grow items-center gap-2">
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<SearchContainer
|
||||||
|
aiMode={aiMode}
|
||||||
|
isMultiVariants={siteSpaces.length > 1}
|
||||||
|
spaceTitle={siteSpace.title}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
{aiMode === CustomizationAIMode.Assistant ? (
|
||||||
|
<AIChatButton trademark={customization.trademark.enabled} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
|
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
|
||||||
@@ -71,15 +82,13 @@ export function SpaceLayout(props: {
|
|||||||
enabled={withTracking}
|
enabled={withTracking}
|
||||||
appURL={GITBOOK_APP_URL}
|
appURL={GITBOOK_APP_URL}
|
||||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||||
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
|
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||||
>
|
>
|
||||||
<Announcement context={context} />
|
<Announcement context={context} />
|
||||||
<Header
|
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
|
||||||
withTopHeader={withTopHeader}
|
{aiMode === CustomizationAIMode.Assistant ? (
|
||||||
withAIChat={withAIChat}
|
<AIChat trademark={customization.trademark.enabled} />
|
||||||
context={context}
|
) : null}
|
||||||
/>
|
|
||||||
{withAIChat ? <AIChat /> : null}
|
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
|
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
|
||||||
<div
|
<div
|
||||||
@@ -88,7 +97,7 @@ export function SpaceLayout(props: {
|
|||||||
'flex-col',
|
'flex-col',
|
||||||
'lg:flex-row',
|
'lg:flex-row',
|
||||||
CONTAINER_STYLE,
|
CONTAINER_STYLE,
|
||||||
'site-full-width:max-w-full',
|
'site-width-wide:max-w-full',
|
||||||
|
|
||||||
// Ensure the footer is display below the viewport even if the content is not enough
|
// Ensure the footer is display below the viewport even if the content is not enough
|
||||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||||
@@ -103,7 +112,7 @@ export function SpaceLayout(props: {
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
'hidden',
|
'hidden',
|
||||||
'pr-4',
|
'pr-4',
|
||||||
'lg:flex',
|
'md:flex',
|
||||||
'grow-0',
|
'grow-0',
|
||||||
'flex-wrap',
|
'flex-wrap',
|
||||||
'dark:shadow-light/1',
|
'dark:shadow-light/1',
|
||||||
@@ -117,27 +126,7 @@ export function SpaceLayout(props: {
|
|||||||
innerHeader={
|
innerHeader={
|
||||||
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
|
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
|
||||||
<>
|
<>
|
||||||
{!withTopHeader && (
|
{!withTopHeader && searchAndAI}
|
||||||
<div className={tcls('hidden', 'lg:block')}>
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<SearchButton>
|
|
||||||
<span className={tcls('flex-1')}>
|
|
||||||
{t(
|
|
||||||
getSpaceLanguage(customization),
|
|
||||||
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
|
|
||||||
customization.aiSearch
|
|
||||||
?.enabled ||
|
|
||||||
customization.ai?.mode !==
|
|
||||||
CustomizationAIMode.None
|
|
||||||
? 'search_or_ask'
|
|
||||||
: 'search'
|
|
||||||
)}
|
|
||||||
...
|
|
||||||
</span>
|
|
||||||
</SearchButton>
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!withTopHeader && withSections && sections && (
|
{!withTopHeader && withSections && sections && (
|
||||||
<SiteSectionList
|
<SiteSectionList
|
||||||
className={tcls('hidden', 'lg:block')}
|
className={tcls('hidden', 'lg:block')}
|
||||||
@@ -168,19 +157,6 @@ export function SpaceLayout(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{withFooter ? <Footer context={context} /> : null}
|
{withFooter ? <Footer context={context} /> : null}
|
||||||
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<SearchModal
|
|
||||||
spaceTitle={siteSpace.title}
|
|
||||||
// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
|
|
||||||
withAsk={
|
|
||||||
customization.aiSearch?.enabled ||
|
|
||||||
customization.ai?.mode !== CustomizationAIMode.None
|
|
||||||
}
|
|
||||||
withAIChat={withAIChat}
|
|
||||||
isMultiVariants={isMultiVariants}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
</InsightsProvider>
|
</InsightsProvider>
|
||||||
</CurrentContentProvider>
|
</CurrentContentProvider>
|
||||||
</SpaceLayoutContextProvider>
|
</SpaceLayoutContextProvider>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export function Trademark(props: {
|
|||||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||||
|
|
||||||
|
'rounded-lg',
|
||||||
|
'straight-corners:rounded-none',
|
||||||
|
'circular-corners:rounded-2xl',
|
||||||
|
|
||||||
'before:hidden',
|
'before:hidden',
|
||||||
'lg:before:block',
|
'lg:before:block',
|
||||||
'before:content-[""]',
|
'before:content-[""]',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
// We cannot use a ref here because the contextId gets reset on navigation
|
||||||
|
// Probably because of this bug https://github.com/vercel/next.js/issues/67542
|
||||||
|
let previousContextId: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that clears the router cache on contextId change.
|
||||||
|
* This is useful for ensuring that the router does not cache stale data for adaptive content.
|
||||||
|
*/
|
||||||
|
export function useClearRouterCache(contextId: string | undefined) {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousContextId === undefined) {
|
||||||
|
// On the first run, we set the previousContextId to the current contextId
|
||||||
|
previousContextId = contextId;
|
||||||
|
return; // Skip the first run to avoid unnecessary reload
|
||||||
|
}
|
||||||
|
// Initially, previousContextId will be undefined, so we only clear the cache
|
||||||
|
// if contextId has changed from a defined value to a new value.
|
||||||
|
// This prevents unnecessary cache clearing on the first render.
|
||||||
|
if (contextId !== previousContextId && previousContextId !== undefined) {
|
||||||
|
previousContextId = contextId;
|
||||||
|
// Trigger a full reload to clear the in-memory cache
|
||||||
|
router.refresh(); // This will clear the cache and re-fetch the data
|
||||||
|
}
|
||||||
|
}, [contextId, router]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile(breakpoint = 640): boolean {
|
||||||
|
const [isMobile, setIsMobile] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Use matchMedia for a single, efficient listener.
|
||||||
|
// 0.02px fudge keeps it safely below the breakpoint (mirrors Tailwind’s 639.98px).
|
||||||
|
const query = `(max-width: ${breakpoint - 0.02}px)`;
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
|
||||||
|
// Sync initial state if it differs (e.g., on hydration).
|
||||||
|
setIsMobile(media.matches);
|
||||||
|
|
||||||
|
const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
|
||||||
|
setIsMobile(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
media.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ export const CONTAINER_STYLE: ClassValue = [
|
|||||||
'md:px-8',
|
'md:px-8',
|
||||||
'max-w-screen-2xl',
|
'max-w-screen-2xl',
|
||||||
'mx-auto',
|
'mx-auto',
|
||||||
// 'site-full-width:max-w-full',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ButtonHTMLAttributes, HTMLAttributeAnchorTarget, HTMLAttributes } from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
@@ -11,14 +11,17 @@ import { Tooltip } from './Tooltip';
|
|||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
href?: string;
|
href?: string;
|
||||||
variant?: 'primary' | 'secondary' | 'blank';
|
variant?: 'primary' | 'secondary' | 'blank' | 'header';
|
||||||
icon?: IconName | React.ReactNode;
|
icon?: IconName | React.ReactNode;
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
size?: 'default' | 'medium' | 'small';
|
size?: 'default' | 'medium' | 'small' | 'xsmall';
|
||||||
className?: ClassValue;
|
className?: ClassValue;
|
||||||
label?: string;
|
label?: string | React.ReactNode;
|
||||||
|
trailing?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
active?: boolean;
|
||||||
} & LinkInsightsProps &
|
} & LinkInsightsProps &
|
||||||
HTMLAttributes<HTMLElement>;
|
React.HTMLAttributes<HTMLElement>;
|
||||||
|
|
||||||
export const variantClasses = {
|
export const variantClasses = {
|
||||||
primary: [
|
primary: [
|
||||||
@@ -26,19 +29,23 @@ export const variantClasses = {
|
|||||||
'text-contrast-primary-solid',
|
'text-contrast-primary-solid',
|
||||||
'hover:bg-primary-solid-hover',
|
'hover:bg-primary-solid-hover',
|
||||||
'hover:text-contrast-primary-solid-hover',
|
'hover:text-contrast-primary-solid-hover',
|
||||||
'ring-0',
|
'border-0',
|
||||||
'contrast-more:ring-1',
|
'contrast-more:border-1',
|
||||||
|
'disabled:bg-tint',
|
||||||
|
'disabled:text-tint/8',
|
||||||
],
|
],
|
||||||
blank: [
|
blank: [
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'text-tint',
|
'text-tint',
|
||||||
'ring-0',
|
'border-0',
|
||||||
'shadow-none',
|
'!shadow-none',
|
||||||
'hover:bg-primary-hover',
|
'hover:bg-primary-hover',
|
||||||
'hover:text-primary',
|
'hover:text-primary-strong',
|
||||||
'hover:scale-1',
|
'hover:scale-1',
|
||||||
'hover:shadow-none',
|
|
||||||
'contrast-more:bg-tint-subtle',
|
'contrast-more:bg-tint-subtle',
|
||||||
|
'depth-subtle:hover:translate-y-0',
|
||||||
|
'disabled:text-tint/8',
|
||||||
|
'disabled:bg-transparent',
|
||||||
],
|
],
|
||||||
secondary: [
|
secondary: [
|
||||||
'bg-tint',
|
'bg-tint',
|
||||||
@@ -48,10 +55,44 @@ export const variantClasses = {
|
|||||||
'depth-flat:hover:bg-tint-hover',
|
'depth-flat:hover:bg-tint-hover',
|
||||||
'hover:text-primary',
|
'hover:text-primary',
|
||||||
'contrast-more:bg-tint-subtle',
|
'contrast-more:bg-tint-subtle',
|
||||||
|
'disabled:bg-transparent',
|
||||||
|
'disabled:text-tint/8',
|
||||||
|
],
|
||||||
|
header: [
|
||||||
|
'bg-tint-base text-tint',
|
||||||
|
'theme-clean:hover:bg-tint-subtle',
|
||||||
|
|
||||||
|
'theme-bold:bg-header-link/2',
|
||||||
|
'theme-bold:text-header-link',
|
||||||
|
'theme-bold:!shadow-none',
|
||||||
|
'theme-bold:border-header-link/4',
|
||||||
|
|
||||||
|
'theme-bold:hover:bg-header-link/3',
|
||||||
|
'theme-bold:hover:text-header-link',
|
||||||
|
'theme-bold:hover:shadow-none',
|
||||||
|
'theme-bold:hover:border-header-link/5',
|
||||||
|
|
||||||
|
'theme-bold:contrast-more:bg-header-background',
|
||||||
|
'theme-bold:contrast-more:text-header-link',
|
||||||
|
'theme-bold:contrast-more:border-header-link',
|
||||||
|
'theme-bold:contrast-more:hover:border-header-link',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
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',
|
||||||
|
secondary: 'bg-tint-active',
|
||||||
|
header: 'bg-header-link/3',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<
|
||||||
|
HTMLButtonElement | HTMLAnchorElement,
|
||||||
|
ButtonProps &
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement> & { target?: React.HTMLAttributeAnchorTarget }
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
href,
|
href,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
@@ -61,30 +102,33 @@ export function Button({
|
|||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
trailing,
|
||||||
|
disabled,
|
||||||
...rest
|
...rest
|
||||||
}: ButtonProps & ButtonHTMLAttributes<HTMLButtonElement> & { target?: HTMLAttributeAnchorTarget }) {
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
|
default: ['text-base', 'font-semibold', 'px-5', 'py-2'],
|
||||||
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
|
medium: ['text-sm', iconOnly ? 'px-2' : 'px-3.5', 'py-1.5'],
|
||||||
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
|
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
|
||||||
|
xsmall: ['text-xs', 'py-1', iconOnly ? 'px-1.5' : 'px-2'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = sizes[size] || sizes.default;
|
const sizeClasses = sizes[size] || sizes.default;
|
||||||
|
|
||||||
const domClassName = tcls(variantClasses[variant], sizeClasses, className);
|
const domClassName = tcls(
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses,
|
||||||
|
active && activeClasses[variant],
|
||||||
|
className
|
||||||
|
);
|
||||||
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
|
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
|
||||||
|
|
||||||
if (href) {
|
const content = (
|
||||||
return (
|
<>
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={domClassName}
|
|
||||||
classNames={['ButtonStyles']}
|
|
||||||
insights={insights}
|
|
||||||
aria-label={label}
|
|
||||||
target={target}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{icon ? (
|
{icon ? (
|
||||||
typeof icon === 'string' ? (
|
typeof icon === 'string' ? (
|
||||||
<Icon icon={icon as IconName} className={tcls('size-[1em]')} />
|
<Icon icon={icon as IconName} className={tcls('size-[1em]')} />
|
||||||
@@ -93,27 +137,65 @@ export function Button({
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{iconOnly ? null : label}
|
{iconOnly ? null : label}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||||
|
href={href}
|
||||||
|
className={domClassName}
|
||||||
|
classNames={['ButtonStyles']}
|
||||||
|
insights={insights}
|
||||||
|
aria-label={label?.toString()}
|
||||||
|
aria-pressed={active === undefined ? undefined : active}
|
||||||
|
target={target}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
type="button"
|
type="button"
|
||||||
className={tcls(buttonOnlyClassNames, domClassName)}
|
className={tcls(buttonOnlyClassNames, domClassName)}
|
||||||
aria-label={label}
|
aria-label={label?.toString()}
|
||||||
|
aria-pressed={active === undefined ? undefined : active}
|
||||||
|
disabled={disabled}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{icon ? (
|
{content}
|
||||||
typeof icon === 'string' ? (
|
|
||||||
<Icon icon={icon as IconName} className={tcls('size-[1em]')} />
|
|
||||||
) : (
|
|
||||||
icon
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
{iconOnly ? null : label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return iconOnly ? <Tooltip label={label}>{button}</Tooltip> : button;
|
return iconOnly && label ? (
|
||||||
}
|
<Tooltip
|
||||||
|
rootProps={{ open: disabled === true ? false : undefined }}
|
||||||
|
label={label}
|
||||||
|
triggerProps={{ disabled }}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonProps>(({ children }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={tcls(
|
||||||
|
'*:!transform-none *:!shadow-none flex h-fit items-stretch justify-start',
|
||||||
|
'[&>*:not(:first-child)]:border-l-0 [&>*:not(:first-child,:last-child)]:rounded-none [&>*:not(:only-child):first-child]:rounded-r-none [&>*:not(:only-child):last-child]:rounded-l-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||||
import { useState } from 'react';
|
import { createContext, useCallback, useContext, useState } from 'react';
|
||||||
|
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
|
|
||||||
|
import { assert } from 'ts-essentials';
|
||||||
import { Link, type LinkInsightsProps } from '.';
|
import { Link, type LinkInsightsProps } from '.';
|
||||||
|
|
||||||
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
|
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
|
||||||
@@ -15,6 +16,14 @@ export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
|
|||||||
'ref'
|
'ref'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuContext = createContext<{
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
setOpen: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button with a dropdown.
|
* Button with a dropdown.
|
||||||
*/
|
*/
|
||||||
@@ -27,22 +36,38 @@ export function DropdownMenu(props: {
|
|||||||
className?: ClassValue;
|
className?: ClassValue;
|
||||||
/** Open the dropdown on hover */
|
/** Open the dropdown on hover */
|
||||||
openOnHover?: boolean;
|
openOnHover?: boolean;
|
||||||
|
/**
|
||||||
|
* Side of the dropdown
|
||||||
|
* @default "bottom"
|
||||||
|
*/
|
||||||
|
side?: RadixDropdownMenu.DropdownMenuContentProps['side'];
|
||||||
|
/**
|
||||||
|
* Alignment of the dropdown
|
||||||
|
* @default "start"
|
||||||
|
*/
|
||||||
|
align?: RadixDropdownMenu.DropdownMenuContentProps['align'];
|
||||||
}) {
|
}) {
|
||||||
const { button, children, className, openOnHover = false } = props;
|
const {
|
||||||
|
button,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
openOnHover = false,
|
||||||
|
side = 'bottom',
|
||||||
|
align = 'start',
|
||||||
|
} = props;
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [clicked, setClicked] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const isOpen = openOnHover ? open || hovered : open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadixDropdownMenu.Root
|
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
|
||||||
modal={false}
|
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
|
||||||
open={openOnHover ? clicked || hovered : clicked}
|
|
||||||
onOpenChange={setClicked}
|
|
||||||
>
|
|
||||||
<RadixDropdownMenu.Trigger
|
<RadixDropdownMenu.Trigger
|
||||||
asChild
|
asChild
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => (openOnHover ? setClicked(!clicked) : null)}
|
onClick={() => (openOnHover ? setOpen(!open) : null)}
|
||||||
className="group/dropdown"
|
className="group/dropdown"
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
@@ -55,12 +80,13 @@ export function DropdownMenu(props: {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
align="start"
|
align={align}
|
||||||
className="z-40 animate-scaleIn pt-2"
|
side={side}
|
||||||
|
className="z-40 animate-scaleIn border-tint pt-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80',
|
'flex max-h-96 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -69,6 +95,7 @@ export function DropdownMenu(props: {
|
|||||||
</RadixDropdownMenu.Content>
|
</RadixDropdownMenu.Content>
|
||||||
</RadixDropdownMenu.Portal>
|
</RadixDropdownMenu.Portal>
|
||||||
</RadixDropdownMenu.Root>
|
</RadixDropdownMenu.Root>
|
||||||
|
</DropdownMenuContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +143,17 @@ export function DropdownButton(props: {
|
|||||||
export function DropdownMenuItem(
|
export function DropdownMenuItem(
|
||||||
props: {
|
props: {
|
||||||
href?: string;
|
href?: string;
|
||||||
|
target?: React.HTMLAttributeAnchorTarget;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
className?: ClassValue;
|
className?: ClassValue;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
} & LinkInsightsProps &
|
} & LinkInsightsProps &
|
||||||
RadixDropdownMenu.DropdownMenuItemProps
|
RadixDropdownMenu.DropdownMenuItemProps
|
||||||
) {
|
) {
|
||||||
const { children, active = false, href, className, insights, ...rest } = props;
|
const { children, active = false, href, className, insights, target, ...rest } = props;
|
||||||
|
|
||||||
const itemClassName = tcls(
|
const itemClassName = tcls(
|
||||||
'rounded straight-corners:rounded-sm px-3 py-1 text-sm flex gap-2 items-center',
|
'rounded straight-corners:rounded-sm circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||||
active
|
active
|
||||||
? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover'
|
? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover'
|
||||||
: 'data-[highlighted]:bg-tint-hover',
|
: 'data-[highlighted]:bg-tint-hover',
|
||||||
@@ -137,7 +165,7 @@ export function DropdownMenuItem(
|
|||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<RadixDropdownMenu.Item {...rest} asChild>
|
<RadixDropdownMenu.Item {...rest} asChild>
|
||||||
<Link href={href} insights={insights} className={itemClassName}>
|
<Link href={href} insights={insights} className={itemClassName} target={target}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
</RadixDropdownMenu.Item>
|
</RadixDropdownMenu.Item>
|
||||||
@@ -166,7 +194,7 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
className="z-40 animate-present"
|
className="z-40 animate-present"
|
||||||
>
|
>
|
||||||
<div className="flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80">
|
<div className="flex max-h-96 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</RadixDropdownMenu.SubContent>
|
</RadixDropdownMenu.SubContent>
|
||||||
@@ -174,3 +202,21 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
|
|||||||
</RadixDropdownMenu.Sub>
|
</RadixDropdownMenu.Sub>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuSeparator(props: { className?: ClassValue }) {
|
||||||
|
const { className } = props;
|
||||||
|
return (
|
||||||
|
<RadixDropdownMenu.Separator
|
||||||
|
className={tcls('my-1 h-px w-full border-tint-subtle border-t', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to close the dropdown menu.
|
||||||
|
*/
|
||||||
|
export function useDropdownMenuClose() {
|
||||||
|
const context = useContext(DropdownMenuContext);
|
||||||
|
assert(context, 'DropdownMenuContext not found');
|
||||||
|
return useCallback(() => context.setOpen(false), [context]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function getOperatingSystem() {
|
||||||
|
const platform = navigator.platform.toLowerCase();
|
||||||
|
|
||||||
|
if (platform.includes('mac')) return 'mac';
|
||||||
|
if (platform.includes('win')) return 'win';
|
||||||
|
|
||||||
|
return 'win';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue }) {
|
||||||
|
const { keys, className } = props;
|
||||||
|
|
||||||
|
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setOperatingSystem(getOperatingSystem());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||||
|
className={tcls(
|
||||||
|
'shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex',
|
||||||
|
operatingSystem
|
||||||
|
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
|
||||||
|
: 'opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<kbd
|
||||||
|
key={key}
|
||||||
|
className={tcls(
|
||||||
|
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
||||||
|
key === 'mod'
|
||||||
|
? operatingSystem === 'mac'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'text-xs'
|
||||||
|
: 'uppercase',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import React from 'react';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { SiteExternalLinksTarget } from '@gitbook/api';
|
import { SiteExternalLinksTarget } from '@gitbook/api';
|
||||||
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
||||||
|
import { isExternalLink } from '../utils/link';
|
||||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
||||||
|
|
||||||
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
|
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
|
||||||
@@ -40,6 +41,26 @@ export const LinkSettingsContext = React.createContext<{
|
|||||||
externalLinksTarget: SiteExternalLinksTarget.Self,
|
externalLinksTarget: SiteExternalLinksTarget.Self,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the target and rel props for a link based on the provided props and context.
|
||||||
|
*/
|
||||||
|
function getTargetProps(
|
||||||
|
props: Pick<LinkProps, 'href' | 'rel' | 'target'>,
|
||||||
|
context: {
|
||||||
|
externalLinksTarget: SiteExternalLinksTarget;
|
||||||
|
isExternal: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const target =
|
||||||
|
props.target ??
|
||||||
|
(context.isExternal && context.externalLinksTarget === SiteExternalLinksTarget.Blank
|
||||||
|
? '_blank'
|
||||||
|
: undefined);
|
||||||
|
// Automatically set rel if target is _blank, or use the specified rel.
|
||||||
|
const rel = props.rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined);
|
||||||
|
return { target, rel };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Low-level Link component that handles navigation to external urls.
|
* Low-level Link component that handles navigation to external urls.
|
||||||
* It does not contain any styling.
|
* It does not contain any styling.
|
||||||
@@ -52,28 +73,33 @@ export const Link = React.forwardRef(function Link(
|
|||||||
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
|
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
const forwardedClassNames = useClassnames(classNames || []);
|
const forwardedClassNames = useClassnames(classNames || []);
|
||||||
|
const isExternal = isExternalLink(href);
|
||||||
// Use a real anchor tag for external links,s and a Next.js Link for internal links.
|
const { target, rel } = getTargetProps(props, { externalLinksTarget, isExternal });
|
||||||
// If we use a NextLink for external links, Nextjs won't rerender the top-level layouts.
|
|
||||||
const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http');
|
|
||||||
|
|
||||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
||||||
|
|
||||||
if (insights) {
|
if (insights) {
|
||||||
trackEvent(insights, undefined, {
|
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
|
||||||
immediate: isExternal,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isInIframe = window.self !== window.top;
|
||||||
isExternalLink(href, window.location.origin) &&
|
|
||||||
// When the page is embedded in an iframe, for security reasons other urls cannot be opened.
|
// When the page is embedded in an iframe
|
||||||
// In this case, we open the link in a new tab.
|
// for security reasons other urls cannot be opened.
|
||||||
(window.self !== window.top ||
|
if (isInIframe && isExternalWithOrigin) {
|
||||||
// If the site is configured to open links in a new tab
|
|
||||||
externalLinksTarget === SiteExternalLinksTarget.Blank)
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.open(href, '_blank');
|
window.open(href, '_blank', 'noopener noreferrer');
|
||||||
|
} else if (isExternal && !event.ctrlKey && !event.metaKey) {
|
||||||
|
// The external logic server-side is limited
|
||||||
|
// so we use the client-side logic to determine the real target
|
||||||
|
// by default the target is "_self".
|
||||||
|
const { target = '_self' } = getTargetProps(props, {
|
||||||
|
externalLinksTarget,
|
||||||
|
isExternal: isExternalWithOrigin,
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
window.open(href, target, rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
domProps.onClick?.(event);
|
domProps.onClick?.(event);
|
||||||
@@ -81,7 +107,7 @@ export const Link = React.forwardRef(function Link(
|
|||||||
|
|
||||||
// We test if the link is external, without comparing to the origin
|
// We test if the link is external, without comparing to the origin
|
||||||
// as this will be rendered on the server and it could result in a mismatch.
|
// as this will be rendered on the server and it could result in a mismatch.
|
||||||
if (isExternalLink(href)) {
|
if (isExternal) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -89,20 +115,23 @@ export const Link = React.forwardRef(function Link(
|
|||||||
{...domProps}
|
{...domProps}
|
||||||
href={href}
|
href={href}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
{...(externalLinksTarget === SiteExternalLinksTarget.Blank
|
target={target}
|
||||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
rel={rel}
|
||||||
: {})}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not sure why yet, but it seems necessary to force prefetch to true
|
||||||
|
// default behavior doesn't seem to properly use the client router cache.
|
||||||
|
const _prefetch = prefetch === null || prefetch === undefined ? true : prefetch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextLink
|
<NextLink
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={href}
|
href={href}
|
||||||
prefetch={prefetch}
|
prefetch={_prefetch}
|
||||||
className={tcls(...forwardedClassNames, className)}
|
className={tcls(...forwardedClassNames, className)}
|
||||||
{...domProps}
|
{...domProps}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -153,27 +182,3 @@ export const LinkOverlay = React.forwardRef(function LinkOverlay(
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a link is external, compared to an origin.
|
|
||||||
*/
|
|
||||||
function isExternalLink(href: string, origin: string | null = null) {
|
|
||||||
if (!URL.canParse) {
|
|
||||||
// If URL.canParse is not available, we quickly check if it looks like a URL
|
|
||||||
return href.startsWith('http');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!URL.canParse(href)) {
|
|
||||||
// If we can't parse the href, we consider it a relative path
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!origin) {
|
|
||||||
// If origin is not provided, we consider the link external
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the url points to the same origin, we consider it internal
|
|
||||||
const parsed = new URL(href);
|
|
||||||
return parsed.origin !== origin;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,30 +4,28 @@ import * as RadixPopover from '@radix-ui/react-popover';
|
|||||||
export function Popover(props: {
|
export function Popover(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
content?: string | React.ReactNode;
|
content?: string | React.ReactNode;
|
||||||
|
rootProps?: RadixPopover.PopoverProps;
|
||||||
triggerProps?: RadixPopover.PopoverTriggerProps;
|
triggerProps?: RadixPopover.PopoverTriggerProps;
|
||||||
contentProps?: RadixPopover.PopoverContentProps;
|
contentProps?: RadixPopover.PopoverContentProps;
|
||||||
arrow?: boolean;
|
arrow?: boolean;
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { children, content, triggerProps, contentProps, arrow = false, className } = props;
|
const { children, content, rootProps, triggerProps, contentProps, arrow = false } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadixPopover.Root>
|
<RadixPopover.Root {...rootProps}>
|
||||||
<RadixPopover.Trigger asChild {...triggerProps}>
|
<RadixPopover.Trigger {...triggerProps}>{children}</RadixPopover.Trigger>
|
||||||
{children}
|
|
||||||
</RadixPopover.Trigger>
|
|
||||||
<RadixPopover.Portal>
|
<RadixPopover.Portal>
|
||||||
<RadixPopover.Content
|
<RadixPopover.Content
|
||||||
collisionPadding={16}
|
{...contentProps}
|
||||||
sideOffset={4}
|
collisionPadding={contentProps?.collisionPadding ?? 16}
|
||||||
|
sideOffset={contentProps?.sideOffset ?? 4}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'z-50 max-w-xs animate-scaleIn overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint shadow-md outline-none ring-1 ring-tint',
|
'z-50 max-h-[var(--radix-popover-content-available-height)] max-w-xs animate-scaleIn overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-lg shadow-tint outline-none ring-1 ring-tint transition-all empty:hidden',
|
||||||
className
|
contentProps?.className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: 'var(--radix-popover-content-available-height)',
|
...contentProps?.style,
|
||||||
}}
|
}}
|
||||||
{...contentProps}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
{arrow && (
|
{arrow && (
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import * as RadixTooltip from '@radix-ui/react-tooltip';
|
import * as RadixTooltip from '@radix-ui/react-tooltip';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
|
||||||
export function Tooltip(props: {
|
export function Tooltip(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -7,6 +11,7 @@ export function Tooltip(props: {
|
|||||||
providerProps?: RadixTooltip.TooltipProviderProps;
|
providerProps?: RadixTooltip.TooltipProviderProps;
|
||||||
triggerProps?: RadixTooltip.TooltipTriggerProps;
|
triggerProps?: RadixTooltip.TooltipTriggerProps;
|
||||||
contentProps?: RadixTooltip.TooltipContentProps;
|
contentProps?: RadixTooltip.TooltipContentProps;
|
||||||
|
rootProps?: RadixTooltip.TooltipProps;
|
||||||
arrow?: boolean;
|
arrow?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -16,14 +21,29 @@ export function Tooltip(props: {
|
|||||||
providerProps,
|
providerProps,
|
||||||
triggerProps,
|
triggerProps,
|
||||||
contentProps,
|
contentProps,
|
||||||
|
rootProps,
|
||||||
arrow = false,
|
arrow = false,
|
||||||
className,
|
className,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadixTooltip.Provider delayDuration={300} {...providerProps}>
|
<RadixTooltip.Provider delayDuration={300} {...providerProps}>
|
||||||
<RadixTooltip.Root>
|
<RadixTooltip.Root open={open} onOpenChange={(e) => setOpen(e)} {...rootProps}>
|
||||||
<RadixTooltip.Trigger asChild {...triggerProps}>
|
<RadixTooltip.Trigger
|
||||||
|
asChild
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isMobile) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
{...triggerProps}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</RadixTooltip.Trigger>
|
</RadixTooltip.Trigger>
|
||||||
<RadixTooltip.Portal>
|
<RadixTooltip.Portal>
|
||||||
@@ -33,6 +53,12 @@ export function Tooltip(props: {
|
|||||||
'z-50 max-w-xs animate-scaleIn circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-12 px-2 py-1 text-contrast-tint-12 text-sm',
|
'z-50 max-w-xs animate-scaleIn circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-12 px-2 py-1 text-contrast-tint-12 text-sm',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isMobile) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
{...contentProps}
|
{...contentProps}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -2,44 +2,40 @@ import type { ClassValue } from '@/lib/tailwind';
|
|||||||
|
|
||||||
export const ButtonStyles = [
|
export const ButtonStyles = [
|
||||||
'button',
|
'button',
|
||||||
|
'group/button',
|
||||||
'inline-flex',
|
'inline-flex',
|
||||||
'items-center',
|
'items-center',
|
||||||
'gap-2',
|
'gap-2',
|
||||||
'rounded-md',
|
'rounded-md',
|
||||||
'straight-corners:rounded-none',
|
'straight-corners:rounded-none',
|
||||||
'circular-corners:rounded-full',
|
'circular-corners:rounded-3xl',
|
||||||
// 'place-self-start',
|
|
||||||
|
|
||||||
'ring-1',
|
'border',
|
||||||
'ring-tint',
|
'border-tint',
|
||||||
'hover:ring-tint-hover',
|
'hover:border-tint-hover',
|
||||||
|
|
||||||
'shadow-sm',
|
'depth-subtle:shadow-sm',
|
||||||
|
'depth-subtle:hover:shadow-md',
|
||||||
|
'depth-subtle:focus-visible:shadow-md',
|
||||||
|
'active:shadow-none',
|
||||||
'shadow-tint',
|
'shadow-tint',
|
||||||
'dark:shadow-tint-1',
|
'dark:shadow-tint-1',
|
||||||
'hover:shadow-md',
|
|
||||||
'active:shadow-none',
|
|
||||||
'depth-flat:shadow-none',
|
|
||||||
|
|
||||||
'contrast-more:ring-tint-12',
|
'contrast-more:border-tint-12',
|
||||||
'contrast-more:hover:ring-2',
|
'contrast-more:hover:border-2',
|
||||||
'contrast-more:hover:ring-tint-12',
|
'contrast-more:hover:border-tint-12',
|
||||||
|
|
||||||
'hover:scale-104',
|
'depth-subtle:hover:-translate-y-px',
|
||||||
'depth-flat:hover:scale-100',
|
'depth-subtle:focus-visible:-translate-y-px',
|
||||||
'active:scale-100',
|
|
||||||
'transition-all',
|
'transition-all',
|
||||||
|
|
||||||
'grow-0',
|
'grow-0',
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
'truncate',
|
'truncate',
|
||||||
|
|
||||||
'disabled:opacity-50',
|
|
||||||
'disabled:cursor-not-allowed',
|
'disabled:cursor-not-allowed',
|
||||||
'disabled:bg-tint',
|
'disabled:!translate-y-0',
|
||||||
'disabled:text-tint/8',
|
'disabled:!shadow-none',
|
||||||
'disabled:shadow-none',
|
|
||||||
'disabled:hover:scale-100',
|
|
||||||
] as ClassValue[];
|
] as ClassValue[];
|
||||||
|
|
||||||
export const CardStyles = [
|
export const CardStyles = [
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
|
||||||
|
export const isAIChatEnabled = (context: GitBookSiteContext) =>
|
||||||
|
context.customization.ai.mode === CustomizationAIMode.Assistant;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Check if a link is external, compared to an origin.
|
||||||
|
*/
|
||||||
|
export function isExternalLink(href: string, origin: string | null = null) {
|
||||||
|
if (!URL.canParse) {
|
||||||
|
// If URL.canParse is not available, we quickly check if it looks like a URL
|
||||||
|
return href.startsWith('http');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!URL.canParse(href)) {
|
||||||
|
// If we can't parse the href, we consider it a relative path
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!origin) {
|
||||||
|
// If origin is not provided, we consider the link external
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the url points to the same origin, we consider it internal
|
||||||
|
const parsed = new URL(href);
|
||||||
|
return parsed.origin !== origin;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export const de = {
|
|||||||
search_results_count: '${1} Ergebnisse',
|
search_results_count: '${1} Ergebnisse',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Alle Inhalte',
|
search_scope_all: 'Alle Inhalte',
|
||||||
|
ask: 'Fragen',
|
||||||
search_ask: 'Fragen "${1}"',
|
search_ask: 'Fragen "${1}"',
|
||||||
search_ask_description: 'Finden Sie die Antwort mit AI',
|
search_ask_description: 'Finden Sie die Antwort mit AI',
|
||||||
search_ask_sources: 'Quellen',
|
search_ask_sources: 'Quellen',
|
||||||
@@ -31,6 +32,8 @@ export const de = {
|
|||||||
was_this_helpful_negative: 'Nein',
|
was_this_helpful_negative: 'Nein',
|
||||||
was_this_helpful_thank_you: 'Danke!',
|
was_this_helpful_thank_you: 'Danke!',
|
||||||
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
|
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
|
||||||
|
was_this_helpful_positive_label: 'Hilfreich',
|
||||||
|
was_this_helpful_negative_label: 'Nicht hilfreich',
|
||||||
submit: 'Absenden',
|
submit: 'Absenden',
|
||||||
annotation_button_label: 'Kommentar öffnen',
|
annotation_button_label: 'Kommentar öffnen',
|
||||||
code_copied: 'Kopiert!',
|
code_copied: 'Kopiert!',
|
||||||
@@ -64,7 +67,8 @@ export const de = {
|
|||||||
link_tooltip_external_link: 'Externe Verlinkung zu',
|
link_tooltip_external_link: 'Externe Verlinkung zu',
|
||||||
link_tooltip_page_anchor: 'Zum Abschnitt springen',
|
link_tooltip_page_anchor: 'Zum Abschnitt springen',
|
||||||
open_in_new_tab: 'In neuem Tab öffnen',
|
open_in_new_tab: 'In neuem Tab öffnen',
|
||||||
ai_chat_assistant_name: 'Docs-Assistent',
|
ai_chat_assistant_name: 'GitBook-Assistent',
|
||||||
|
ai_chat_assistant_name_unbranded: 'KI-Assistent',
|
||||||
ai_chat_assistant_description: 'Ich helfe Ihnen bei der Dokumentation.',
|
ai_chat_assistant_description: 'Ich helfe Ihnen bei der Dokumentation.',
|
||||||
ai_chat_assistant_greeting_morning: 'Guten Morgen',
|
ai_chat_assistant_greeting_morning: 'Guten Morgen',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Guten Tag',
|
ai_chat_assistant_greeting_afternoon: 'Guten Tag',
|
||||||
@@ -77,13 +81,14 @@ export const de = {
|
|||||||
ai_chat_error: 'Etwas ist schief gelaufen.',
|
ai_chat_error: 'Etwas ist schief gelaufen.',
|
||||||
ai_chat_context_title: 'Basierend auf Ihrem Kontext',
|
ai_chat_context_title: 'Basierend auf Ihrem Kontext',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'Der Docs-Assistent verwendet KI und Ihren Kontext, um Antworten zu generieren und Aktionen durchzuführen.',
|
'Der Assistent verwendet KI und Ihren Kontext, um Antworten zu generieren und Aktionen durchzuführen.',
|
||||||
ai_chat_context_pages_youve_read: 'Seiten, die Sie gelesen haben',
|
ai_chat_context_pages_youve_read: 'Seiten, die Sie gelesen haben',
|
||||||
ai_chat_context_info_provided_by_the_site: 'Von der Website bereitgestellte Informationen',
|
ai_chat_context_info_provided_by_the_site: 'Von der Website bereitgestellte Informationen',
|
||||||
ai_chat_context_previous_messages: 'Vorherige Nachrichten',
|
ai_chat_context_previous_messages: 'Vorherige Nachrichten',
|
||||||
ai_chat_context_disclaimer: 'KI-Antworten können Fehler enthalten.',
|
ai_chat_context_disclaimer: 'KI-Antworten können Fehler enthalten.',
|
||||||
ai_chat_input_placeholder: 'Fragen, suchen oder Aktion ausführen...',
|
ai_chat_input_placeholder: 'Fragen, suchen oder Aktion ausführen...',
|
||||||
send: 'Senden',
|
send: 'Senden',
|
||||||
|
actions: 'Aktionen',
|
||||||
ai_chat_suggested_questions_title: 'Vorgeschlagene Fragen',
|
ai_chat_suggested_questions_title: 'Vorgeschlagene Fragen',
|
||||||
ai_chat_suggested_questions_about_this_page: 'Worum geht es auf dieser Seite?',
|
ai_chat_suggested_questions_about_this_page: 'Worum geht es auf dieser Seite?',
|
||||||
ai_chat_suggested_questions_read_next: 'Was sollte ich als nächstes lesen?',
|
ai_chat_suggested_questions_read_next: 'Was sollte ich als nächstes lesen?',
|
||||||
@@ -92,4 +97,13 @@ export const de = {
|
|||||||
ai_chat_tools_listed_pages: 'Docs durchsucht',
|
ai_chat_tools_listed_pages: 'Docs durchsucht',
|
||||||
ai_chat_tools_read_page: 'Seite ${1} gelesen',
|
ai_chat_tools_read_page: 'Seite ${1} gelesen',
|
||||||
ai_chat_tools_mcp_tool: 'Aufgerufen ${1}',
|
ai_chat_tools_mcp_tool: 'Aufgerufen ${1}',
|
||||||
|
ai_chat_ask: 'Frage ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Frage ${1} zu dieser Seite',
|
||||||
|
copy_for_llms: 'Für LLMs kopieren',
|
||||||
|
copy_page_markdown: 'Seite als Markdown für LLMs kopieren',
|
||||||
|
copy_page: 'Seite kopieren',
|
||||||
|
view_page_markdown: 'Als Markdown anzeigen',
|
||||||
|
view_page_plaintext: 'Diese Seite als Klartext anzeigen',
|
||||||
|
open_in: 'Öffnen in ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const en = {
|
|||||||
search_results_count: '${1} results',
|
search_results_count: '${1} results',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'All content',
|
search_scope_all: 'All content',
|
||||||
|
ask: 'Ask',
|
||||||
search_ask: 'Ask "${1}"',
|
search_ask: 'Ask "${1}"',
|
||||||
search_ask_description: 'Find the answer with AI',
|
search_ask_description: 'Find the answer with AI',
|
||||||
search_ask_sources: 'Sources',
|
search_ask_sources: 'Sources',
|
||||||
@@ -31,6 +32,8 @@ export const en = {
|
|||||||
was_this_helpful_negative: 'No',
|
was_this_helpful_negative: 'No',
|
||||||
was_this_helpful_thank_you: 'Thank you!',
|
was_this_helpful_thank_you: 'Thank you!',
|
||||||
was_this_helpful_comment: "Anything you'd like to add?",
|
was_this_helpful_comment: "Anything you'd like to add?",
|
||||||
|
was_this_helpful_positive_label: 'Helpful',
|
||||||
|
was_this_helpful_negative_label: 'Not helpful',
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
annotation_button_label: 'Open annotation',
|
annotation_button_label: 'Open annotation',
|
||||||
code_copied: 'Copied!',
|
code_copied: 'Copied!',
|
||||||
@@ -62,7 +65,8 @@ export const en = {
|
|||||||
link_tooltip_external_link: 'External link to',
|
link_tooltip_external_link: 'External link to',
|
||||||
link_tooltip_page_anchor: 'Jump to section',
|
link_tooltip_page_anchor: 'Jump to section',
|
||||||
open_in_new_tab: 'Open in new tab',
|
open_in_new_tab: 'Open in new tab',
|
||||||
ai_chat_assistant_name: 'Docs Assistant',
|
ai_chat_assistant_name: 'GitBook Assistant',
|
||||||
|
ai_chat_assistant_name_unbranded: 'AI Assistant',
|
||||||
ai_chat_assistant_description: "I'm here to help you with the docs.",
|
ai_chat_assistant_description: "I'm here to help you with the docs.",
|
||||||
ai_chat_assistant_greeting_morning: 'Good morning',
|
ai_chat_assistant_greeting_morning: 'Good morning',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Good afternoon',
|
ai_chat_assistant_greeting_afternoon: 'Good afternoon',
|
||||||
@@ -75,13 +79,14 @@ export const en = {
|
|||||||
ai_chat_error: 'Something went wrong.',
|
ai_chat_error: 'Something went wrong.',
|
||||||
ai_chat_context_title: 'Based on your context',
|
ai_chat_context_title: 'Based on your context',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'Docs Assistant uses AI and your context to generate answers and perform actions.',
|
'The assistant uses AI and your context to generate answers and perform actions.',
|
||||||
ai_chat_context_pages_youve_read: "Pages you've read",
|
ai_chat_context_pages_youve_read: "Pages you've read",
|
||||||
ai_chat_context_info_provided_by_the_site: 'Info provided by the site',
|
ai_chat_context_info_provided_by_the_site: 'Info provided by the site',
|
||||||
ai_chat_context_previous_messages: 'Previous messages',
|
ai_chat_context_previous_messages: 'Previous messages',
|
||||||
ai_chat_context_disclaimer: 'AI responses may contain mistakes.',
|
ai_chat_context_disclaimer: 'AI responses may contain mistakes.',
|
||||||
ai_chat_input_placeholder: 'Ask, search, or take action...',
|
ai_chat_input_placeholder: 'Ask, search, or take action...',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
|
actions: 'Actions',
|
||||||
ai_chat_suggested_questions_title: 'Suggested questions',
|
ai_chat_suggested_questions_title: 'Suggested questions',
|
||||||
ai_chat_suggested_questions_about_this_page: 'What is this page about?',
|
ai_chat_suggested_questions_about_this_page: 'What is this page about?',
|
||||||
ai_chat_suggested_questions_read_next: 'What should I read next?',
|
ai_chat_suggested_questions_read_next: 'What should I read next?',
|
||||||
@@ -90,4 +95,13 @@ export const en = {
|
|||||||
ai_chat_tools_listed_pages: 'Browsed the docs',
|
ai_chat_tools_listed_pages: 'Browsed the docs',
|
||||||
ai_chat_tools_read_page: 'Read page ${1}',
|
ai_chat_tools_read_page: 'Read page ${1}',
|
||||||
ai_chat_tools_mcp_tool: 'Called ${1}',
|
ai_chat_tools_mcp_tool: 'Called ${1}',
|
||||||
|
ai_chat_ask: 'Ask ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Ask ${1} about this page',
|
||||||
|
copy_for_llms: 'Copy for LLMs',
|
||||||
|
copy_page_markdown: 'Copy page as Markdown for LLMs',
|
||||||
|
copy_page: 'Copy page',
|
||||||
|
view_page_markdown: 'View as Markdown',
|
||||||
|
view_page_plaintext: 'View this page as plain text',
|
||||||
|
open_in: 'Open in ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const es: TranslationLanguage = {
|
|||||||
search_results_count: '${1} resultados',
|
search_results_count: '${1} resultados',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Todo el contenido',
|
search_scope_all: 'Todo el contenido',
|
||||||
|
ask: 'Preguntar',
|
||||||
search_ask: 'Preguntar "${1}"',
|
search_ask: 'Preguntar "${1}"',
|
||||||
search_ask_description: 'Encuentra la respuesta con IA',
|
search_ask_description: 'Encuentra la respuesta con IA',
|
||||||
search_ask_sources: 'Fuentes',
|
search_ask_sources: 'Fuentes',
|
||||||
@@ -33,6 +34,8 @@ export const es: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: 'No',
|
was_this_helpful_negative: 'No',
|
||||||
was_this_helpful_thank_you: '¡Gracias!',
|
was_this_helpful_thank_you: '¡Gracias!',
|
||||||
was_this_helpful_comment: '¿Algo más que te gustaría añadir?',
|
was_this_helpful_comment: '¿Algo más que te gustaría añadir?',
|
||||||
|
was_this_helpful_positive_label: 'Útil',
|
||||||
|
was_this_helpful_negative_label: 'No útil',
|
||||||
submit: 'Enviar',
|
submit: 'Enviar',
|
||||||
annotation_button_label: 'Abrir anotación',
|
annotation_button_label: 'Abrir anotación',
|
||||||
code_copied: '¡Copiado!',
|
code_copied: '¡Copiado!',
|
||||||
@@ -66,7 +69,8 @@ export const es: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: 'Enlace externo a',
|
link_tooltip_external_link: 'Enlace externo a',
|
||||||
link_tooltip_page_anchor: 'Saltar a la sección',
|
link_tooltip_page_anchor: 'Saltar a la sección',
|
||||||
open_in_new_tab: 'Abrir en una nueva pestaña',
|
open_in_new_tab: 'Abrir en una nueva pestaña',
|
||||||
ai_chat_assistant_name: 'Asistente de Docs',
|
ai_chat_assistant_name: 'Asistente de GitBook',
|
||||||
|
ai_chat_assistant_name_unbranded: 'Asistente de IA',
|
||||||
ai_chat_assistant_description: 'Estoy aquí para ayudarte con la documentación.',
|
ai_chat_assistant_description: 'Estoy aquí para ayudarte con la documentación.',
|
||||||
ai_chat_assistant_greeting_morning: 'Buenos días',
|
ai_chat_assistant_greeting_morning: 'Buenos días',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Buenas tardes',
|
ai_chat_assistant_greeting_afternoon: 'Buenas tardes',
|
||||||
@@ -79,13 +83,14 @@ export const es: TranslationLanguage = {
|
|||||||
ai_chat_error: 'Algo salió mal.',
|
ai_chat_error: 'Algo salió mal.',
|
||||||
ai_chat_context_title: 'Basado en tu contexto',
|
ai_chat_context_title: 'Basado en tu contexto',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'El Asistente de Docs usa IA y tu contexto para generar respuestas y realizar acciones.',
|
'El asistente usa IA y tu contexto para generar respuestas y realizar acciones.',
|
||||||
ai_chat_context_pages_youve_read: 'Páginas que has leído',
|
ai_chat_context_pages_youve_read: 'Páginas que has leído',
|
||||||
ai_chat_context_info_provided_by_the_site: 'Información proporcionada por el sitio',
|
ai_chat_context_info_provided_by_the_site: 'Información proporcionada por el sitio',
|
||||||
ai_chat_context_previous_messages: 'Mensajes anteriores',
|
ai_chat_context_previous_messages: 'Mensajes anteriores',
|
||||||
ai_chat_context_disclaimer: 'Las respuestas de IA pueden contener errores.',
|
ai_chat_context_disclaimer: 'Las respuestas de IA pueden contener errores.',
|
||||||
ai_chat_input_placeholder: 'Pregunta, busca o realiza una acción...',
|
ai_chat_input_placeholder: 'Pregunta, busca o realiza una acción...',
|
||||||
send: 'Enviar',
|
send: 'Enviar',
|
||||||
|
actions: 'Acciones',
|
||||||
ai_chat_suggested_questions_title: 'Preguntas sugeridas',
|
ai_chat_suggested_questions_title: 'Preguntas sugeridas',
|
||||||
ai_chat_suggested_questions_about_this_page: '¿De qué trata esta página?',
|
ai_chat_suggested_questions_about_this_page: '¿De qué trata esta página?',
|
||||||
ai_chat_suggested_questions_read_next: '¿Qué debería leer después?',
|
ai_chat_suggested_questions_read_next: '¿Qué debería leer después?',
|
||||||
@@ -94,4 +99,13 @@ export const es: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: 'Exploró los docs',
|
ai_chat_tools_listed_pages: 'Exploró los docs',
|
||||||
ai_chat_tools_read_page: 'Leyó la página ${1}',
|
ai_chat_tools_read_page: 'Leyó la página ${1}',
|
||||||
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
|
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
|
||||||
|
ai_chat_ask: 'Preguntar ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página',
|
||||||
|
copy_for_llms: 'Copiar para LLMs',
|
||||||
|
copy_page_markdown: 'Copiar página como Markdown para LLMs',
|
||||||
|
copy_page: 'Copiar página',
|
||||||
|
view_page_markdown: 'Ver como Markdown',
|
||||||
|
view_page_plaintext: 'Ver esta página como texto plano',
|
||||||
|
open_in: 'Abrir en ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const fr: TranslationLanguage = {
|
|||||||
search_results_count: '${1} résultats',
|
search_results_count: '${1} résultats',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Tout le contenu',
|
search_scope_all: 'Tout le contenu',
|
||||||
|
ask: 'Demander',
|
||||||
search_ask: 'Demander "${1}"',
|
search_ask: 'Demander "${1}"',
|
||||||
search_ask_description: "Trouvez la réponse avec l'IA",
|
search_ask_description: "Trouvez la réponse avec l'IA",
|
||||||
search_ask_sources: 'Sources',
|
search_ask_sources: 'Sources',
|
||||||
@@ -33,6 +34,8 @@ export const fr: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: 'Non',
|
was_this_helpful_negative: 'Non',
|
||||||
was_this_helpful_thank_you: 'Merci!',
|
was_this_helpful_thank_you: 'Merci!',
|
||||||
was_this_helpful_comment: 'Quelque chose à ajouter?',
|
was_this_helpful_comment: 'Quelque chose à ajouter?',
|
||||||
|
was_this_helpful_positive_label: 'Utile',
|
||||||
|
was_this_helpful_negative_label: 'Pas utile',
|
||||||
submit: 'Soumettre',
|
submit: 'Soumettre',
|
||||||
annotation_button_label: "Ouvrir l'annotation",
|
annotation_button_label: "Ouvrir l'annotation",
|
||||||
code_copied: 'Copié !',
|
code_copied: 'Copié !',
|
||||||
@@ -64,7 +67,8 @@ export const fr: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: 'Lien externe à',
|
link_tooltip_external_link: 'Lien externe à',
|
||||||
link_tooltip_page_anchor: 'Sauter à la section',
|
link_tooltip_page_anchor: 'Sauter à la section',
|
||||||
open_in_new_tab: 'Ouvrir dans un nouvel onglet',
|
open_in_new_tab: 'Ouvrir dans un nouvel onglet',
|
||||||
ai_chat_assistant_name: 'Assistant Docs',
|
ai_chat_assistant_name: 'Assistant GitBook',
|
||||||
|
ai_chat_assistant_name_unbranded: 'Assistant IA',
|
||||||
ai_chat_assistant_description: 'Je suis là pour vous aider avec la documentation.',
|
ai_chat_assistant_description: 'Je suis là pour vous aider avec la documentation.',
|
||||||
ai_chat_assistant_greeting_morning: 'Bonjour',
|
ai_chat_assistant_greeting_morning: 'Bonjour',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Bon après-midi',
|
ai_chat_assistant_greeting_afternoon: 'Bon après-midi',
|
||||||
@@ -77,13 +81,14 @@ export const fr: TranslationLanguage = {
|
|||||||
ai_chat_error: 'Une erreur est survenue.',
|
ai_chat_error: 'Une erreur est survenue.',
|
||||||
ai_chat_context_title: 'Basé sur votre contexte',
|
ai_chat_context_title: 'Basé sur votre contexte',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
"L'Assistant Docs utilise l'IA et votre contexte pour générer des réponses et effectuer des actions.",
|
"L'assistant utilise l'IA et votre contexte pour générer des réponses et effectuer des actions.",
|
||||||
ai_chat_context_pages_youve_read: 'Pages que vous avez lues',
|
ai_chat_context_pages_youve_read: 'Pages que vous avez lues',
|
||||||
ai_chat_context_info_provided_by_the_site: 'Informations fournies par le site',
|
ai_chat_context_info_provided_by_the_site: 'Informations fournies par le site',
|
||||||
ai_chat_context_previous_messages: 'Messages précédents',
|
ai_chat_context_previous_messages: 'Messages précédents',
|
||||||
ai_chat_context_disclaimer: "Les réponses de l'IA peuvent contenir des erreurs.",
|
ai_chat_context_disclaimer: "Les réponses de l'IA peuvent contenir des erreurs.",
|
||||||
ai_chat_input_placeholder: 'Demander, rechercher ou effectuer une action...',
|
ai_chat_input_placeholder: 'Demander, rechercher ou effectuer une action...',
|
||||||
send: 'Envoyer',
|
send: 'Envoyer',
|
||||||
|
actions: 'Actions',
|
||||||
ai_chat_suggested_questions_title: 'Questions suggérées',
|
ai_chat_suggested_questions_title: 'Questions suggérées',
|
||||||
ai_chat_suggested_questions_about_this_page: 'De quoi parle cette page ?',
|
ai_chat_suggested_questions_about_this_page: 'De quoi parle cette page ?',
|
||||||
ai_chat_suggested_questions_read_next: 'Que devrais-je lire ensuite ?',
|
ai_chat_suggested_questions_read_next: 'Que devrais-je lire ensuite ?',
|
||||||
@@ -92,4 +97,13 @@ export const fr: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: 'Parcouru les docs',
|
ai_chat_tools_listed_pages: 'Parcouru les docs',
|
||||||
ai_chat_tools_read_page: 'Lu la page ${1}',
|
ai_chat_tools_read_page: 'Lu la page ${1}',
|
||||||
ai_chat_tools_mcp_tool: 'Appelé ${1}',
|
ai_chat_tools_mcp_tool: 'Appelé ${1}',
|
||||||
|
ai_chat_ask: 'Demander à ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Demander à ${1} au sujet de cette page',
|
||||||
|
copy_for_llms: 'Copier pour les LLMs',
|
||||||
|
copy_page_markdown: 'Copier la page en Markdown pour les LLMs',
|
||||||
|
copy_page: 'Copier la page',
|
||||||
|
view_page_markdown: 'Voir en Markdown',
|
||||||
|
view_page_plaintext: 'Voir cette page en texte brut',
|
||||||
|
open_in: 'Ouvrir dans ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Lis ${1} et réponds aux questions sur le contenu.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const ja: TranslationLanguage = {
|
|||||||
search_results_count: '${1}件の結果',
|
search_results_count: '${1}件の結果',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: '全てのコンテンツ',
|
search_scope_all: '全てのコンテンツ',
|
||||||
|
ask: '質問する',
|
||||||
search_ask: '"${1}" を質問する',
|
search_ask: '"${1}" を質問する',
|
||||||
search_ask_description: 'AIで答えを見つける',
|
search_ask_description: 'AIで答えを見つける',
|
||||||
search_ask_sources: '情報源',
|
search_ask_sources: '情報源',
|
||||||
@@ -33,6 +34,8 @@ export const ja: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: 'いいえ',
|
was_this_helpful_negative: 'いいえ',
|
||||||
was_this_helpful_thank_you: 'ありがとうございます!',
|
was_this_helpful_thank_you: 'ありがとうございます!',
|
||||||
was_this_helpful_comment: '何か追加したいことはありますか?',
|
was_this_helpful_comment: '何か追加したいことはありますか?',
|
||||||
|
was_this_helpful_positive_label: '役立つ',
|
||||||
|
was_this_helpful_negative_label: '役立たない',
|
||||||
submit: '送信',
|
submit: '送信',
|
||||||
annotation_button_label: '注釈を開く',
|
annotation_button_label: '注釈を開く',
|
||||||
code_copied: 'コピーしました!',
|
code_copied: 'コピーしました!',
|
||||||
@@ -64,7 +67,8 @@ export const ja: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: '外部リンク先',
|
link_tooltip_external_link: '外部リンク先',
|
||||||
link_tooltip_page_anchor: 'ページ内リンク先',
|
link_tooltip_page_anchor: 'ページ内リンク先',
|
||||||
open_in_new_tab: '新しいタブで開く',
|
open_in_new_tab: '新しいタブで開く',
|
||||||
ai_chat_assistant_name: 'ドキュメントアシスタント',
|
ai_chat_assistant_name: 'GitBookアシスタント',
|
||||||
|
ai_chat_assistant_name_unbranded: 'AIアシスタント',
|
||||||
ai_chat_assistant_description: 'ドキュメントについてお手伝いします。',
|
ai_chat_assistant_description: 'ドキュメントについてお手伝いします。',
|
||||||
ai_chat_assistant_greeting_morning: 'おはようございます',
|
ai_chat_assistant_greeting_morning: 'おはようございます',
|
||||||
ai_chat_assistant_greeting_afternoon: 'こんにちは',
|
ai_chat_assistant_greeting_afternoon: 'こんにちは',
|
||||||
@@ -77,13 +81,14 @@ export const ja: TranslationLanguage = {
|
|||||||
ai_chat_error: '何らかのエラーが発生しました。',
|
ai_chat_error: '何らかのエラーが発生しました。',
|
||||||
ai_chat_context_title: 'あなたのコンテキストに基づいて',
|
ai_chat_context_title: 'あなたのコンテキストに基づいて',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'ドキュメントアシスタントはAIとあなたのコンテキストを使用して回答を生成し、アクションを実行します。',
|
'アシスタントはAIとあなたのコンテキストを使用して回答を生成し、アクションを実行します。',
|
||||||
ai_chat_context_pages_youve_read: '読んだページ',
|
ai_chat_context_pages_youve_read: '読んだページ',
|
||||||
ai_chat_context_info_provided_by_the_site: 'サイトから提供された情報',
|
ai_chat_context_info_provided_by_the_site: 'サイトから提供された情報',
|
||||||
ai_chat_context_previous_messages: '以前のメッセージ',
|
ai_chat_context_previous_messages: '以前のメッセージ',
|
||||||
ai_chat_context_disclaimer: 'AIの回答には誤りが含まれる場合があります。',
|
ai_chat_context_disclaimer: 'AIの回答には誤りが含まれる場合があります。',
|
||||||
ai_chat_input_placeholder: '質問、検索、またはアクションを実行...',
|
ai_chat_input_placeholder: '質問、検索、またはアクションを実行...',
|
||||||
send: '送信',
|
send: '送信',
|
||||||
|
actions: 'アクション',
|
||||||
ai_chat_suggested_questions_title: 'おすすめの質問',
|
ai_chat_suggested_questions_title: 'おすすめの質問',
|
||||||
ai_chat_suggested_questions_about_this_page: 'このページは何について説明していますか?',
|
ai_chat_suggested_questions_about_this_page: 'このページは何について説明していますか?',
|
||||||
ai_chat_suggested_questions_read_next: '次に何を読むべきですか?',
|
ai_chat_suggested_questions_read_next: '次に何を読むべきですか?',
|
||||||
@@ -92,4 +97,13 @@ export const ja: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: 'ドキュメントを閲覧',
|
ai_chat_tools_listed_pages: 'ドキュメントを閲覧',
|
||||||
ai_chat_tools_read_page: 'ページ ${1} を読みました',
|
ai_chat_tools_read_page: 'ページ ${1} を読みました',
|
||||||
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
|
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
|
||||||
|
ai_chat_ask: '${1} に質問する',
|
||||||
|
ai_chat_ask_about_page: 'このページについて ${1} に質問する',
|
||||||
|
copy_for_llms: 'LLM 用にコピー',
|
||||||
|
copy_page_markdown: 'ページを Markdown として LLM 用にコピー',
|
||||||
|
copy_page: 'ページをコピー',
|
||||||
|
view_page_markdown: 'Markdown として表示',
|
||||||
|
view_page_plaintext: 'このページをプレーンテキストで表示',
|
||||||
|
open_in: '${1} で開く',
|
||||||
|
open_in_llms_pre_prompt: '${1} を読んで内容に関する質問に答えてください。',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nl: TranslationLanguage = {
|
|||||||
search_results_count: '${1} resultaten',
|
search_results_count: '${1} resultaten',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Alle inhoud',
|
search_scope_all: 'Alle inhoud',
|
||||||
|
ask: 'Vragen',
|
||||||
search_ask: 'Vraag "${1}"',
|
search_ask: 'Vraag "${1}"',
|
||||||
search_ask_description: 'Vind het antwoord met AI',
|
search_ask_description: 'Vind het antwoord met AI',
|
||||||
search_ask_sources: 'Bronnen',
|
search_ask_sources: 'Bronnen',
|
||||||
@@ -33,6 +34,8 @@ export const nl: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: 'Nee',
|
was_this_helpful_negative: 'Nee',
|
||||||
was_this_helpful_thank_you: 'Bedankt!',
|
was_this_helpful_thank_you: 'Bedankt!',
|
||||||
was_this_helpful_comment: 'Nog iets toe te voegen?',
|
was_this_helpful_comment: 'Nog iets toe te voegen?',
|
||||||
|
was_this_helpful_positive_label: 'Nuttig',
|
||||||
|
was_this_helpful_negative_label: 'Niet nuttig',
|
||||||
submit: 'Versturen',
|
submit: 'Versturen',
|
||||||
annotation_button_label: 'Open annotatie',
|
annotation_button_label: 'Open annotatie',
|
||||||
code_copied: 'Gekopieerd!',
|
code_copied: 'Gekopieerd!',
|
||||||
@@ -64,7 +67,8 @@ export const nl: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: 'Externe link naar',
|
link_tooltip_external_link: 'Externe link naar',
|
||||||
link_tooltip_page_anchor: 'Spring naar sectie',
|
link_tooltip_page_anchor: 'Spring naar sectie',
|
||||||
open_in_new_tab: 'Open in nieuw tabblad',
|
open_in_new_tab: 'Open in nieuw tabblad',
|
||||||
ai_chat_assistant_name: 'Docs Assistent',
|
ai_chat_assistant_name: 'GitBook Assistent',
|
||||||
|
ai_chat_assistant_name_unbranded: 'AI Assistent',
|
||||||
ai_chat_assistant_description: 'Ik help je met de documentatie.',
|
ai_chat_assistant_description: 'Ik help je met de documentatie.',
|
||||||
ai_chat_assistant_greeting_morning: 'Goedemorgen',
|
ai_chat_assistant_greeting_morning: 'Goedemorgen',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Goedemiddag',
|
ai_chat_assistant_greeting_afternoon: 'Goedemiddag',
|
||||||
@@ -77,13 +81,14 @@ export const nl: TranslationLanguage = {
|
|||||||
ai_chat_error: 'Er is iets misgegaan.',
|
ai_chat_error: 'Er is iets misgegaan.',
|
||||||
ai_chat_context_title: 'Gebaseerd op je context',
|
ai_chat_context_title: 'Gebaseerd op je context',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'De Docs Assistent gebruikt AI en je context om antwoorden te genereren en acties uit te voeren.',
|
'De assistent gebruikt AI en je context om antwoorden te genereren en acties uit te voeren.',
|
||||||
ai_chat_context_pages_youve_read: "Pagina's die je hebt gelezen",
|
ai_chat_context_pages_youve_read: "Pagina's die je hebt gelezen",
|
||||||
ai_chat_context_info_provided_by_the_site: 'Informatie verstrekt door de site',
|
ai_chat_context_info_provided_by_the_site: 'Informatie verstrekt door de site',
|
||||||
ai_chat_context_previous_messages: 'Vorige berichten',
|
ai_chat_context_previous_messages: 'Vorige berichten',
|
||||||
ai_chat_context_disclaimer: 'AI-antwoorden kunnen fouten bevatten.',
|
ai_chat_context_disclaimer: 'AI-antwoorden kunnen fouten bevatten.',
|
||||||
ai_chat_input_placeholder: 'Vraag, zoek of voer een actie uit...',
|
ai_chat_input_placeholder: 'Vraag, zoek of voer een actie uit...',
|
||||||
send: 'Versturen',
|
send: 'Versturen',
|
||||||
|
actions: 'Acties',
|
||||||
ai_chat_suggested_questions_title: 'Voorgestelde vragen',
|
ai_chat_suggested_questions_title: 'Voorgestelde vragen',
|
||||||
ai_chat_suggested_questions_about_this_page: 'Waar gaat deze pagina over?',
|
ai_chat_suggested_questions_about_this_page: 'Waar gaat deze pagina over?',
|
||||||
ai_chat_suggested_questions_read_next: 'Wat moet ik hierna lezen?',
|
ai_chat_suggested_questions_read_next: 'Wat moet ik hierna lezen?',
|
||||||
@@ -92,4 +97,13 @@ export const nl: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: 'Docs doorzocht',
|
ai_chat_tools_listed_pages: 'Docs doorzocht',
|
||||||
ai_chat_tools_read_page: 'Pagina ${1} gelezen',
|
ai_chat_tools_read_page: 'Pagina ${1} gelezen',
|
||||||
ai_chat_tools_mcp_tool: '${1} aangeroepen',
|
ai_chat_tools_mcp_tool: '${1} aangeroepen',
|
||||||
|
ai_chat_ask: 'Vraag ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Vraag ${1} over deze pagina',
|
||||||
|
copy_for_llms: 'Kopiëren voor LLMs',
|
||||||
|
copy_page_markdown: 'Pagina kopiëren als Markdown voor LLMs',
|
||||||
|
copy_page: 'Pagina kopiëren',
|
||||||
|
view_page_markdown: 'Bekijk als Markdown',
|
||||||
|
view_page_plaintext: 'Bekijk deze pagina als platte tekst',
|
||||||
|
open_in: 'Open in ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const no: TranslationLanguage = {
|
|||||||
search_results_count: '${1} resultater',
|
search_results_count: '${1} resultater',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Alt innhold',
|
search_scope_all: 'Alt innhold',
|
||||||
|
ask: 'Spør',
|
||||||
search_ask: 'Spør "${1}"',
|
search_ask: 'Spør "${1}"',
|
||||||
search_ask_description: 'Finn svaret med AI',
|
search_ask_description: 'Finn svaret med AI',
|
||||||
search_ask_sources: 'Kilder',
|
search_ask_sources: 'Kilder',
|
||||||
@@ -33,6 +34,8 @@ export const no: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: 'Nei',
|
was_this_helpful_negative: 'Nei',
|
||||||
was_this_helpful_thank_you: 'Takk!',
|
was_this_helpful_thank_you: 'Takk!',
|
||||||
was_this_helpful_comment: 'Noe du vil legge til?',
|
was_this_helpful_comment: 'Noe du vil legge til?',
|
||||||
|
was_this_helpful_positive_label: 'Nyttig',
|
||||||
|
was_this_helpful_negative_label: 'Ikke nyttig',
|
||||||
submit: 'Send inn',
|
submit: 'Send inn',
|
||||||
annotation_button_label: 'Åpne merknad',
|
annotation_button_label: 'Åpne merknad',
|
||||||
code_copied: 'Kopiert!',
|
code_copied: 'Kopiert!',
|
||||||
@@ -64,7 +67,8 @@ export const no: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: 'Ekstern lenke til',
|
link_tooltip_external_link: 'Ekstern lenke til',
|
||||||
link_tooltip_page_anchor: 'Hopp til seksjon',
|
link_tooltip_page_anchor: 'Hopp til seksjon',
|
||||||
open_in_new_tab: 'Åpne i ny fane',
|
open_in_new_tab: 'Åpne i ny fane',
|
||||||
ai_chat_assistant_name: 'Docs-assistent',
|
ai_chat_assistant_name: 'GitBook-assistent',
|
||||||
|
ai_chat_assistant_name_unbranded: 'AI-assistent',
|
||||||
ai_chat_assistant_description: 'Jeg er her for å hjelpe deg med docs.',
|
ai_chat_assistant_description: 'Jeg er her for å hjelpe deg med docs.',
|
||||||
ai_chat_assistant_greeting_morning: 'God morgen',
|
ai_chat_assistant_greeting_morning: 'God morgen',
|
||||||
ai_chat_assistant_greeting_afternoon: 'God ettermiddag',
|
ai_chat_assistant_greeting_afternoon: 'God ettermiddag',
|
||||||
@@ -77,13 +81,14 @@ export const no: TranslationLanguage = {
|
|||||||
ai_chat_error: 'Noe gikk galt.',
|
ai_chat_error: 'Noe gikk galt.',
|
||||||
ai_chat_context_title: 'Basert på din kontekst',
|
ai_chat_context_title: 'Basert på din kontekst',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'Docs-assistenten bruker AI og din kontekst til å generere svar og utføre handlinger.',
|
'Assistenten bruker AI og din kontekst til å generere svar og utføre handlinger.',
|
||||||
ai_chat_context_pages_youve_read: 'Sider du har lest',
|
ai_chat_context_pages_youve_read: 'Sider du har lest',
|
||||||
ai_chat_context_info_provided_by_the_site: 'Informasjon gitt av nettstedet',
|
ai_chat_context_info_provided_by_the_site: 'Informasjon gitt av nettstedet',
|
||||||
ai_chat_context_previous_messages: 'Tidligere meldinger',
|
ai_chat_context_previous_messages: 'Tidligere meldinger',
|
||||||
ai_chat_context_disclaimer: 'AI-svar kan inneholde feil.',
|
ai_chat_context_disclaimer: 'AI-svar kan inneholde feil.',
|
||||||
ai_chat_input_placeholder: 'Spør, søk eller utfør en handling...',
|
ai_chat_input_placeholder: 'Spør, søk eller utfør en handling...',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
|
actions: 'Handlinger',
|
||||||
ai_chat_suggested_questions_title: 'Foreslåtte spørsmål',
|
ai_chat_suggested_questions_title: 'Foreslåtte spørsmål',
|
||||||
ai_chat_suggested_questions_about_this_page: 'Hva handler denne siden om?',
|
ai_chat_suggested_questions_about_this_page: 'Hva handler denne siden om?',
|
||||||
ai_chat_suggested_questions_read_next: 'Hva bør jeg lese neste?',
|
ai_chat_suggested_questions_read_next: 'Hva bør jeg lese neste?',
|
||||||
@@ -92,4 +97,13 @@ export const no: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: 'Bladde gjennom docs',
|
ai_chat_tools_listed_pages: 'Bladde gjennom docs',
|
||||||
ai_chat_tools_read_page: 'Leste side ${1}',
|
ai_chat_tools_read_page: 'Leste side ${1}',
|
||||||
ai_chat_tools_mcp_tool: 'Kallte ${1}',
|
ai_chat_tools_mcp_tool: 'Kallte ${1}',
|
||||||
|
ai_chat_ask: 'Spør ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Spør ${1} om denne siden',
|
||||||
|
copy_for_llms: 'Kopier for LLMs',
|
||||||
|
copy_page_markdown: 'Kopier siden som Markdown for LLMs',
|
||||||
|
copy_page: 'Kopier side',
|
||||||
|
view_page_markdown: 'Vis som Markdown',
|
||||||
|
view_page_plaintext: 'Vis denne siden som ren tekst',
|
||||||
|
open_in: 'Åpne i ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const pt_br = {
|
|||||||
search_results_count: '${1} resultados',
|
search_results_count: '${1} resultados',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: 'Todo o conteúdo',
|
search_scope_all: 'Todo o conteúdo',
|
||||||
|
ask: 'Perguntar',
|
||||||
search_ask: 'Perguntar "${1}"',
|
search_ask: 'Perguntar "${1}"',
|
||||||
search_ask_description: 'Encontre a resposta com IA',
|
search_ask_description: 'Encontre a resposta com IA',
|
||||||
search_ask_sources: 'Fontes',
|
search_ask_sources: 'Fontes',
|
||||||
@@ -31,6 +32,8 @@ export const pt_br = {
|
|||||||
was_this_helpful_negative: 'Não',
|
was_this_helpful_negative: 'Não',
|
||||||
was_this_helpful_thank_you: 'Obrigado!',
|
was_this_helpful_thank_you: 'Obrigado!',
|
||||||
was_this_helpful_comment: 'Gostaria de adicionar algo?',
|
was_this_helpful_comment: 'Gostaria de adicionar algo?',
|
||||||
|
was_this_helpful_positive_label: 'Útil',
|
||||||
|
was_this_helpful_negative_label: 'Não útil',
|
||||||
submit: 'Enviar',
|
submit: 'Enviar',
|
||||||
annotation_button_label: 'Abrir anotação',
|
annotation_button_label: 'Abrir anotação',
|
||||||
code_copied: 'Copiado!',
|
code_copied: 'Copiado!',
|
||||||
@@ -64,7 +67,8 @@ export const pt_br = {
|
|||||||
link_tooltip_external_link: 'Link externo para',
|
link_tooltip_external_link: 'Link externo para',
|
||||||
link_tooltip_page_anchor: 'Pular para a seção',
|
link_tooltip_page_anchor: 'Pular para a seção',
|
||||||
open_in_new_tab: 'Abrir em uma nova guia',
|
open_in_new_tab: 'Abrir em uma nova guia',
|
||||||
ai_chat_assistant_name: 'Assistente de Docs',
|
ai_chat_assistant_name: 'Assistente do GitBook',
|
||||||
|
ai_chat_assistant_name_unbranded: 'Assistente de IA',
|
||||||
ai_chat_assistant_description: 'Estou aqui para ajudá-lo com a documentação.',
|
ai_chat_assistant_description: 'Estou aqui para ajudá-lo com a documentação.',
|
||||||
ai_chat_assistant_greeting_morning: 'Bom dia',
|
ai_chat_assistant_greeting_morning: 'Bom dia',
|
||||||
ai_chat_assistant_greeting_afternoon: 'Boa tarde',
|
ai_chat_assistant_greeting_afternoon: 'Boa tarde',
|
||||||
@@ -77,13 +81,14 @@ export const pt_br = {
|
|||||||
ai_chat_error: 'Algo deu errado.',
|
ai_chat_error: 'Algo deu errado.',
|
||||||
ai_chat_context_title: 'Baseado no seu contexto',
|
ai_chat_context_title: 'Baseado no seu contexto',
|
||||||
ai_chat_context_description:
|
ai_chat_context_description:
|
||||||
'O Assistente de Docs usa IA e seu contexto para gerar respostas e realizar ações.',
|
'O assistente usa IA e seu contexto para gerar respostas e realizar ações.',
|
||||||
ai_chat_context_pages_youve_read: 'Páginas que você leu',
|
ai_chat_context_pages_youve_read: 'Páginas que você leu',
|
||||||
ai_chat_context_info_provided_by_the_site: 'Informações fornecidas pelo site',
|
ai_chat_context_info_provided_by_the_site: 'Informações fornecidas pelo site',
|
||||||
ai_chat_context_previous_messages: 'Mensagens anteriores',
|
ai_chat_context_previous_messages: 'Mensagens anteriores',
|
||||||
ai_chat_context_disclaimer: 'Respostas de IA podem conter erros.',
|
ai_chat_context_disclaimer: 'Respostas de IA podem conter erros.',
|
||||||
ai_chat_input_placeholder: 'Pergunte, pesquise ou execute uma ação...',
|
ai_chat_input_placeholder: 'Pergunte, pesquise ou execute uma ação...',
|
||||||
send: 'Enviar',
|
send: 'Enviar',
|
||||||
|
actions: 'Ações',
|
||||||
ai_chat_suggested_questions_title: 'Perguntas sugeridas',
|
ai_chat_suggested_questions_title: 'Perguntas sugeridas',
|
||||||
ai_chat_suggested_questions_about_this_page: 'Sobre o que é esta página?',
|
ai_chat_suggested_questions_about_this_page: 'Sobre o que é esta página?',
|
||||||
ai_chat_suggested_questions_read_next: 'O que devo ler em seguida?',
|
ai_chat_suggested_questions_read_next: 'O que devo ler em seguida?',
|
||||||
@@ -92,4 +97,13 @@ export const pt_br = {
|
|||||||
ai_chat_tools_listed_pages: 'Navegou pelos docs',
|
ai_chat_tools_listed_pages: 'Navegou pelos docs',
|
||||||
ai_chat_tools_read_page: 'Leu a página ${1}',
|
ai_chat_tools_read_page: 'Leu a página ${1}',
|
||||||
ai_chat_tools_mcp_tool: 'Chamou ${1}',
|
ai_chat_tools_mcp_tool: 'Chamou ${1}',
|
||||||
|
ai_chat_ask: 'Perguntar a ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página',
|
||||||
|
copy_for_llms: 'Copiar para LLMs',
|
||||||
|
copy_page_markdown: 'Copiar página como Markdown para LLMs',
|
||||||
|
copy_page: 'Copiar página',
|
||||||
|
view_page_markdown: 'Ver como Markdown',
|
||||||
|
view_page_plaintext: 'Ver esta página em texto simples',
|
||||||
|
open_in: 'Abrir no ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const zh: TranslationLanguage = {
|
|||||||
search_results_count: '${1} 个结果',
|
search_results_count: '${1} 个结果',
|
||||||
search_scope_space: '${1}',
|
search_scope_space: '${1}',
|
||||||
search_scope_all: '所有内容',
|
search_scope_all: '所有内容',
|
||||||
|
ask: '询问',
|
||||||
search_ask: '询问"${1}"',
|
search_ask: '询问"${1}"',
|
||||||
search_ask_description: '利用人工智能找到答案',
|
search_ask_description: '利用人工智能找到答案',
|
||||||
search_ask_sources: '来源',
|
search_ask_sources: '来源',
|
||||||
@@ -32,6 +33,8 @@ export const zh: TranslationLanguage = {
|
|||||||
was_this_helpful_negative: '不',
|
was_this_helpful_negative: '不',
|
||||||
was_this_helpful_thank_you: '谢谢!',
|
was_this_helpful_thank_you: '谢谢!',
|
||||||
was_this_helpful_comment: '您有什么想补充的吗?',
|
was_this_helpful_comment: '您有什么想补充的吗?',
|
||||||
|
was_this_helpful_positive_label: '有帮助',
|
||||||
|
was_this_helpful_negative_label: '没有帮助',
|
||||||
submit: '提交',
|
submit: '提交',
|
||||||
annotation_button_label: '打开批注',
|
annotation_button_label: '打开批注',
|
||||||
code_copied: '已复制!',
|
code_copied: '已复制!',
|
||||||
@@ -62,7 +65,8 @@ export const zh: TranslationLanguage = {
|
|||||||
link_tooltip_external_link: '外部链接到',
|
link_tooltip_external_link: '外部链接到',
|
||||||
link_tooltip_page_anchor: '跳转到页面',
|
link_tooltip_page_anchor: '跳转到页面',
|
||||||
open_in_new_tab: '在新标签页中打开',
|
open_in_new_tab: '在新标签页中打开',
|
||||||
ai_chat_assistant_name: '文档助手',
|
ai_chat_assistant_name: 'GitBook 助手',
|
||||||
|
ai_chat_assistant_name_unbranded: 'AI 助手',
|
||||||
ai_chat_assistant_description: '我在这里帮助您了解文档。',
|
ai_chat_assistant_description: '我在这里帮助您了解文档。',
|
||||||
ai_chat_assistant_greeting_morning: '早上好',
|
ai_chat_assistant_greeting_morning: '早上好',
|
||||||
ai_chat_assistant_greeting_afternoon: '下午好',
|
ai_chat_assistant_greeting_afternoon: '下午好',
|
||||||
@@ -74,13 +78,14 @@ export const zh: TranslationLanguage = {
|
|||||||
ai_chat_context_badge: 'AI',
|
ai_chat_context_badge: 'AI',
|
||||||
ai_chat_error: '出了些问题。',
|
ai_chat_error: '出了些问题。',
|
||||||
ai_chat_context_title: '基于您的上下文',
|
ai_chat_context_title: '基于您的上下文',
|
||||||
ai_chat_context_description: '文档助手使用人工智能和您的上下文来生成答案并执行操作。',
|
ai_chat_context_description: '助手使用人工智能和您的上下文来生成答案并执行操作。',
|
||||||
ai_chat_context_pages_youve_read: '您已阅读的页面',
|
ai_chat_context_pages_youve_read: '您已阅读的页面',
|
||||||
ai_chat_context_info_provided_by_the_site: '网站提供的信息',
|
ai_chat_context_info_provided_by_the_site: '网站提供的信息',
|
||||||
ai_chat_context_previous_messages: '之前的消息',
|
ai_chat_context_previous_messages: '之前的消息',
|
||||||
ai_chat_context_disclaimer: '人工智能的回答可能包含错误。',
|
ai_chat_context_disclaimer: '人工智能的回答可能包含错误。',
|
||||||
ai_chat_input_placeholder: '询问、搜索或执行操作...',
|
ai_chat_input_placeholder: '询问、搜索或执行操作...',
|
||||||
send: '发送',
|
send: '发送',
|
||||||
|
actions: '操作',
|
||||||
ai_chat_suggested_questions_title: '建议的问题',
|
ai_chat_suggested_questions_title: '建议的问题',
|
||||||
ai_chat_suggested_questions_about_this_page: '这个页面讲的是什么?',
|
ai_chat_suggested_questions_about_this_page: '这个页面讲的是什么?',
|
||||||
ai_chat_suggested_questions_read_next: '我接下来应该读什么?',
|
ai_chat_suggested_questions_read_next: '我接下来应该读什么?',
|
||||||
@@ -89,4 +94,13 @@ export const zh: TranslationLanguage = {
|
|||||||
ai_chat_tools_listed_pages: '浏览了文档',
|
ai_chat_tools_listed_pages: '浏览了文档',
|
||||||
ai_chat_tools_read_page: '已读取页面 ${1}',
|
ai_chat_tools_read_page: '已读取页面 ${1}',
|
||||||
ai_chat_tools_mcp_tool: '调用了 ${1}',
|
ai_chat_tools_mcp_tool: '调用了 ${1}',
|
||||||
|
ai_chat_ask: '向 ${1} 提问',
|
||||||
|
ai_chat_ask_about_page: '向 ${1} 提问有关此页面',
|
||||||
|
copy_for_llms: '为 LLMs 复制',
|
||||||
|
copy_page_markdown: '将页面以 Markdown 格式复制供 LLMs 使用',
|
||||||
|
copy_page: '复制页面',
|
||||||
|
view_page_markdown: '以 Markdown 查看',
|
||||||
|
view_page_plaintext: '以纯文本查看此页面',
|
||||||
|
open_in: '在 ${1} 中打开',
|
||||||
|
open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getDataOrNull,
|
getDataOrNull,
|
||||||
throwIfDataError,
|
throwIfDataError,
|
||||||
} from '@/lib/data';
|
} from '@/lib/data';
|
||||||
|
import { getLogger } from '@/lib/logger';
|
||||||
import { getSiteStructureSections } from '@/lib/sites';
|
import { getSiteStructureSections } from '@/lib/sites';
|
||||||
import type {
|
import type {
|
||||||
ChangeRequest,
|
ChangeRequest,
|
||||||
@@ -24,7 +25,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { assert } from 'ts-essentials';
|
import { assert } from 'ts-essentials';
|
||||||
import { GITBOOK_URL } from './env';
|
import { GITBOOK_URL } from './env';
|
||||||
import { type ImageResizer, createImageResizer } from './images';
|
import { type ImageResizer, createImageResizer } from './images';
|
||||||
import { type GitBookLinker, createLinker } from './links';
|
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data about the site URL. Provided by the middleware.
|
* Data about the site URL. Provided by the middleware.
|
||||||
@@ -43,6 +44,7 @@ export type SiteURLData = Pick<
|
|||||||
| 'siteSection'
|
| 'siteSection'
|
||||||
| 'siteBasePath'
|
| 'siteBasePath'
|
||||||
| 'basePath'
|
| 'basePath'
|
||||||
|
| 'contextId'
|
||||||
> & {
|
> & {
|
||||||
/**
|
/**
|
||||||
* Identifier used for image resizing.
|
* Identifier used for image resizing.
|
||||||
@@ -122,6 +124,9 @@ export type GitBookSiteContext = GitBookSpaceContext & {
|
|||||||
|
|
||||||
/** Scripts to load for the site. */
|
/** Scripts to load for the site. */
|
||||||
scripts: SiteIntegrationScript[];
|
scripts: SiteIntegrationScript[];
|
||||||
|
|
||||||
|
/** Context ID used by adaptive content. It represents an unique identifier for the authentication context */
|
||||||
|
contextId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +204,7 @@ export async function fetchSiteContextByURLLookup(
|
|||||||
shareKey: data.shareKey,
|
shareKey: data.shareKey,
|
||||||
changeRequest: data.changeRequest,
|
changeRequest: data.changeRequest,
|
||||||
revision: data.revision,
|
revision: data.revision,
|
||||||
|
contextId: data.contextId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +222,7 @@ export async function fetchSiteContextByIds(
|
|||||||
shareKey: string | undefined;
|
shareKey: string | undefined;
|
||||||
changeRequest: string | undefined;
|
changeRequest: string | undefined;
|
||||||
revision: string | undefined;
|
revision: string | undefined;
|
||||||
|
contextId?: string;
|
||||||
}
|
}
|
||||||
): Promise<GitBookSiteContext> {
|
): Promise<GitBookSiteContext> {
|
||||||
const { dataFetcher } = baseContext;
|
const { dataFetcher } = baseContext;
|
||||||
@@ -290,10 +297,11 @@ export async function fetchSiteContextByIds(
|
|||||||
return siteSpaceSettings;
|
return siteSpaceSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
|
||||||
// We got the pointer from an API and customizations from another.
|
// We got the pointer from an API and customizations from another.
|
||||||
// It's possible that the two are unsynced leading to not found customizations for the space.
|
// It's possible that the two are unsynced leading to not found customizations for the space.
|
||||||
// It's better to fallback on customization of the site that displaying an error.
|
// It's better to fallback on customization of the site that displaying an error.
|
||||||
console.warn('Customization not found for site space', ids.siteSpace);
|
logger.warn('Customization not found for site space', ids.siteSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
return customizations.site;
|
return customizations.site;
|
||||||
@@ -301,6 +309,9 @@ export async function fetchSiteContextByIds(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...spaceContext,
|
...spaceContext,
|
||||||
|
linker: site.urls.published
|
||||||
|
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
|
||||||
|
: spaceContext.linker,
|
||||||
organizationId: ids.organization,
|
organizationId: ids.organization,
|
||||||
site,
|
site,
|
||||||
siteSpaces,
|
siteSpaces,
|
||||||
@@ -309,6 +320,7 @@ export async function fetchSiteContextByIds(
|
|||||||
structure: siteStructure,
|
structure: siteStructure,
|
||||||
sections,
|
sections,
|
||||||
scripts,
|
scripts,
|
||||||
|
contextId: ids.contextId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type RenderIntegrationUI,
|
type RenderIntegrationUI,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||||
|
import { parse as parseCacheControl } from '@tusbar/cache-control';
|
||||||
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
import { cache } from '../cache';
|
import { cache } from '../cache';
|
||||||
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
|
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
|
||||||
@@ -50,128 +51,139 @@ export function createDataFetcher(
|
|||||||
// API that are tied to the token
|
// API that are tied to the token
|
||||||
//
|
//
|
||||||
getPublishedContentSite(params) {
|
getPublishedContentSite(params) {
|
||||||
return trace('getPublishedContentSite', () =>
|
return getPublishedContentSite(input, {
|
||||||
getPublishedContentSite(input, {
|
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
siteId: params.siteId,
|
siteId: params.siteId,
|
||||||
siteShareKey: params.siteShareKey,
|
siteShareKey: params.siteShareKey,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getSiteRedirectBySource(params) {
|
getSiteRedirectBySource(params) {
|
||||||
return trace('getSiteRedirectBySource', () =>
|
return getSiteRedirectBySource(input, {
|
||||||
getSiteRedirectBySource(input, {
|
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
siteId: params.siteId,
|
siteId: params.siteId,
|
||||||
siteShareKey: params.siteShareKey,
|
siteShareKey: params.siteShareKey,
|
||||||
source: params.source,
|
source: params.source,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getRevision(params) {
|
getRevision(params) {
|
||||||
return trace('getRevision', () =>
|
return getRevision(input, {
|
||||||
getRevision(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
revisionId: params.revisionId,
|
revisionId: params.revisionId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getRevisionPageByPath(params) {
|
getRevisionPageByPath(params) {
|
||||||
return trace('getRevisionPageByPath', () =>
|
return getRevisionPageByPath(input, {
|
||||||
getRevisionPageByPath(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
revisionId: params.revisionId,
|
revisionId: params.revisionId,
|
||||||
path: params.path,
|
path: params.path,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getRevisionPageMarkdown(params) {
|
getRevisionPageMarkdown(params) {
|
||||||
return trace('getRevisionPageMarkdown', () =>
|
return getRevisionPageMarkdown(input, {
|
||||||
getRevisionPageMarkdown(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
revisionId: params.revisionId,
|
revisionId: params.revisionId,
|
||||||
pageId: params.pageId,
|
pageId: params.pageId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getRevisionPageDocument(params) {
|
getRevisionPageDocument(params) {
|
||||||
return trace('getRevisionPageDocument', () =>
|
return getRevisionPageDocument(input, {
|
||||||
getRevisionPageDocument(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
revisionId: params.revisionId,
|
revisionId: params.revisionId,
|
||||||
pageId: params.pageId,
|
pageId: params.pageId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getLatestOpenAPISpecVersionContent(params) {
|
getLatestOpenAPISpecVersionContent(params) {
|
||||||
return trace('getLatestOpenAPISpecVersionContent', () =>
|
return getLatestOpenAPISpecVersionContent(input, {
|
||||||
getLatestOpenAPISpecVersionContent(input, {
|
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
slug: params.slug,
|
slug: params.slug,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getSpace(params) {
|
getSpace(params) {
|
||||||
return trace('getSpace', () =>
|
return getSpace(input, {
|
||||||
getSpace(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
shareKey: params.shareKey,
|
shareKey: params.shareKey,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getChangeRequest(params) {
|
getChangeRequest(params) {
|
||||||
return trace('getChangeRequest', () =>
|
return getChangeRequest(input, {
|
||||||
getChangeRequest(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
changeRequestId: params.changeRequestId,
|
changeRequestId: params.changeRequestId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getDocument(params) {
|
getDocument(params) {
|
||||||
return trace('getDocument', () =>
|
return getDocument(input, {
|
||||||
getDocument(input, {
|
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
documentId: params.documentId,
|
documentId: params.documentId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getComputedDocument(params) {
|
getComputedDocument(params) {
|
||||||
return trace('getComputedDocument', () =>
|
return getComputedDocument(input, {
|
||||||
getComputedDocument(input, {
|
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
source: params.source,
|
source: params.source,
|
||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getEmbedByUrl(params) {
|
getEmbedByUrl(params) {
|
||||||
return trace('getEmbedByUrl', () =>
|
return getEmbedByUrl(input, {
|
||||||
getEmbedByUrl(input, {
|
|
||||||
url: params.url,
|
url: params.url,
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
searchSiteContent(params) {
|
searchSiteContent(params) {
|
||||||
return trace('searchSiteContent', () => searchSiteContent(input, params));
|
return searchSiteContent(input, params);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderIntegrationUi(params) {
|
renderIntegrationUi(params) {
|
||||||
return trace('renderIntegrationUi', () =>
|
return renderIntegrationUi(input, {
|
||||||
renderIntegrationUi(input, {
|
|
||||||
integrationName: params.integrationName,
|
integrationName: params.integrationName,
|
||||||
request: params.request,
|
request: params.request,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserById(userId) {
|
getUserById(userId) {
|
||||||
return trace('getUserById', () => getUserById(input, { userId }));
|
return getUserById(input, { userId });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the cache life from the api response headers.
|
||||||
|
* @param response The response from the API call.
|
||||||
|
* @param defaultCacheLife The default cache life to use if not specified in the response.
|
||||||
|
* @returns nothing
|
||||||
|
*/
|
||||||
|
function cacheLifeFromResponse(
|
||||||
|
response: HttpResponse<unknown, unknown>,
|
||||||
|
defaultCacheLife: 'days' | 'max' | 'hours' | 'minutes' | 'seconds'
|
||||||
|
) {
|
||||||
|
const cacheControlHeader = response.headers.get('x-gitbook-cache-control');
|
||||||
|
const parsed = parseCacheControl(cacheControlHeader || '');
|
||||||
|
const maxAge = parsed?.maxAge ?? parsed?.sharedMaxAge;
|
||||||
|
if (maxAge) {
|
||||||
|
return cacheLife({
|
||||||
|
stale: 60 * 5, // This one is only for the client,
|
||||||
|
revalidate: maxAge, // revalidate and expire are the same, we don't want stale data here
|
||||||
|
expire: maxAge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Typings in Next is "wrong" and does not allow us just use it as `cacheLife(defaultCacheLife)`
|
||||||
|
switch (defaultCacheLife) {
|
||||||
|
case 'days':
|
||||||
|
return cacheLife('days');
|
||||||
|
case 'max':
|
||||||
|
return cacheLife('max');
|
||||||
|
case 'hours':
|
||||||
|
return cacheLife('hours');
|
||||||
|
case 'minutes':
|
||||||
|
return cacheLife('minutes');
|
||||||
|
case 'seconds':
|
||||||
|
return cacheLife('seconds');
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown default cache life: ${defaultCacheLife}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => {
|
const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
return wrapCacheDataFetcherError(async () => {
|
return wrapCacheDataFetcherError(async () => {
|
||||||
@@ -333,7 +345,7 @@ const getRevisionPageDocument = cache(
|
|||||||
);
|
);
|
||||||
|
|
||||||
cacheTag(...getCacheTagsFromResponse(res));
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
cacheLife('max');
|
cacheLifeFromResponse(res, 'max');
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
@@ -387,7 +399,7 @@ const getDocument = cache(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
cacheTag(...getCacheTagsFromResponse(res));
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
cacheLife('max');
|
cacheLifeFromResponse(res, 'max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -432,7 +444,7 @@ const getComputedDocument = cache(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
cacheTag(...getCacheTagsFromResponse(res));
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
cacheLife('max');
|
cacheLifeFromResponse(res, 'max');
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user