Compare commits

...

75 Commits

Author SHA1 Message Date
Zeno Kapitein 365f60d2ca First try 2025-12-17 15:19:07 +01:00
Zeno Kapitein c716df39ba Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-16 19:49:33 +01:00
Zeno Kapitein f478ddc2ed Create <Input> component (#3857) 2025-12-16 19:43:43 +01:00
Zeno Kapitein bd11c0d21b Fix trademark rounded corners in embed (#3874) 2025-12-16 15:10:47 +01:00
Zeno Kapitein 381728970e Fix overflowing section groups (#3873) 2025-12-15 20:41:00 +01:00
Taran Vohra 4690e2087a Roll out meta links resolution to all sites (#3872) 2025-12-15 21:19:34 +05:30
John Cashman 224a44daf8 Fix priority assignment in sitemap generation (#3854) 2025-12-15 15:41:29 +00:00
Nolann B. 4766092ff9 Fix missing properties in allOf/oneOf (#3871) 2025-12-13 12:38:09 +01:00
conico974 3e4e0b0bf0 bump next again (#3870) 2025-12-12 20:01:11 +01:00
Samy Pessé 6b985051f3 Use response.id instead of responseId (#3867) 2025-12-12 13:52:05 +01:00
Johan Preynat 2d08a6e6de Return not found when decodeURIComponent from params fail (#3863) 2025-12-12 13:27:19 +01:00
Zeno Kapitein 8ee84f130f Update SpaceLayout.tsx 2025-12-12 11:53:46 +01:00
conico974 d539409fc7 Bump Next.js version to 15.4.9 (#3864) 2025-12-12 08:46:04 +01:00
Zeno Kapitein aa86711d5a More spacing 2025-12-11 19:33:38 +01:00
Zeno Kapitein 5347917a47 Spacing 2025-12-11 19:16:24 +01:00
Zeno Kapitein 43e8deca0f Add margin to first document item 2025-12-11 17:56:49 +01:00
Taran Vohra c30ed46021 Bump meta links resolution percentage to 50% (#3807) 2025-12-11 21:58:16 +05:30
Zeno Kapitein 4cdab37eac Rework table of contents to accommodate faded edges 2025-12-11 17:27:49 +01:00
Zeno Kapitein 8a887fd890 Fix tests 2025-12-11 14:39:00 +01:00
Zeno Kapitein 2f6ea5b1f0 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 14:28:55 +01:00
Zeno Kapitein dcd03554bd Update TOC to use normal scrollcontainer 2025-12-11 14:28:26 +01:00
Zeno Kapitein 9281b5e3b7 Update SideSheet.tsx 2025-12-11 14:26:46 +01:00
Zeno Kapitein fc98a4a881 Fix rounded corners on list-line TOC style (#3861) 2025-12-11 10:51:03 +00:00
Zeno Kapitein 6278a1844e Support greeting and fix suggested questions if there are no custom questions (#3856) 2025-12-11 11:45:42 +01:00
Zeno Kapitein 86e7aa6c5e Fix search results cursor not updating (#3858) 2025-12-11 11:45:33 +01:00
Zeno Kapitein ec0b6075b0 Update AnnouncementBanner.tsx 2025-12-11 10:24:15 +01:00
Zeno Kapitein e372dae552 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 10:11:28 +01:00
Nolann B. 8761cee1bb Enhance discriminator handling in OpenAPISchema (#3855) 2025-12-11 08:11:14 +01:00
Zeno Kapitein 44feb3b140 Rework dynamic icons (AIChatIcon, AISearchIcon) (#3853) 2025-12-09 12:15:05 +01:00
conico974 6b43773752 chore: update dependencies and remove Suspense on ISR requests (#3852) 2025-12-08 09:26:15 +01:00
Zeno Kapitein 62438f447e Update FontAwesome to 7.1.0 (#3851) 2025-12-06 12:54:04 +01:00
Nolann B. 87d68ea59e Fix OpenAPI oneOf/allOf merge (#3844) 2025-12-05 17:26:18 +01:00
Claire Chabas 5f9c80e4f2 Use link title for footer links and page cards (#3849) 2025-12-05 14:25:43 +01:00
Zeno Kapitein e329b41764 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-05 14:13:46 +01:00
Zeno Kapitein 9c69cc413f Add customizable suggested questions (#3789) 2025-12-05 13:25:26 +01:00
Zeno Kapitein c8956720d0 Update Header.tsx 2025-12-04 18:32:37 +01:00
Zeno Kapitein 843635225d Make sidesheet work well in docs embed 2025-12-04 18:17:31 +01:00
Zeno Kapitein f78e9ca34e Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-04 17:36:31 +01:00
Claire Chabas b77623b9f3 Fix link title fallback in ToC item (#3845) 2025-12-04 12:58:20 +01:00
Zeno Kapitein 1430ceebea Update Docs Embed with new styling and tabs (#3823) 2025-12-04 10:01:25 +01:00
Zeno Kapitein 3fbd6f0d50 Hide empty Footer correctly (#3842) 2025-12-03 12:21:38 +00:00
Claire Chabas 8f0b97906b Support new "link title" page option (#3798) 2025-12-03 13:20:22 +01:00
Zeno Kapitein d460dc4b39 Update Stepper styling for theme-muted (#3841) 2025-12-03 12:18:44 +00:00
Zeno Kapitein 27b9f7817b Remove corner radius of hint block with heading (#3839) 2025-12-03 10:43:02 +00:00
Zeno Kapitein 9e062b10d3 Make semantic text colors more vibrant (#3838) 2025-12-03 10:39:41 +01:00
Zeno Kapitein 72a06718e2 Layout 2025-12-02 19:22:21 +01:00
Zeno Kapitein 0a7979ede2 More layout fixes 2025-12-02 17:49:51 +01:00
Zeno Kapitein fb49a27cc1 Layout tweaks 2025-12-02 16:04:39 +01:00
Zeno Kapitein 555a1da3ab Changeset 2025-12-02 14:10:13 +01:00
Zeno Kapitein b9f9623970 Fix scroll gutter 2025-12-02 14:08:23 +01:00
Zeno Kapitein a7b80c3c4f Small fixes 2025-12-02 13:54:32 +01:00
Zeno Kapitein e05f262f46 Get ready for supporting side sections list
(not enabled for now)
2025-12-02 13:50:38 +01:00
Zeno Kapitein f53e0427e4 Naming and docs 2025-12-02 12:52:02 +01:00
Zeno Kapitein 0d44191666 Fix trademark & other visual bugs 2025-12-02 11:40:28 +01:00
Nolann B. 74f89316d9 Fix hidden section not found (#3836) 2025-12-02 11:37:55 +01:00
Zeno Kapitein 9e1d7b296b Update AIChat context card open delay (#3834) 2025-12-02 10:24:18 +01:00
Zeno Kapitein 9e669f162a Add SideSheet component, refactor TOC and AIChat to use it
Also fixes RND-7803
2025-12-02 10:09:24 +01:00
Nolann B. b49caafa9c Support for hidden sections (#3831) 2025-12-01 14:48:48 +01:00
Zeno Kapitein f372eec393 Make annotations visible on hover, fix accessibility and outro animations (#3822) 2025-11-28 17:15:29 +01:00
Zeno Kapitein 65ae941687 Replace AI Chat generated content in e2e (#3833) 2025-11-28 16:46:04 +01:00
Viktor Renkema 75b8efa155 Remove sticky prop on Updates block (#3832) 2025-11-28 10:08:21 +01:00
Utku Ufuk 34b0c67c91 Stop filtering by language when site space language is unspecified (#3825) 2025-11-27 17:57:57 +03:00
Zeno Kapitein e0ace99707 Fix flaky tests in AI Chat greeting (#3828) 2025-11-27 09:50:03 +01:00
Viktor Renkema 3553db0989 Add RSS feed for pages with update blocks (#3824)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-11-26 20:34:25 +01:00
Zeno Kapitein 5c14948cf3 Prevent flaky tests in AI Chat followup suggestions (#3827) 2025-11-26 18:39:51 +01:00
conico974 8e4e2a2333 Update wranglerVersion to 4.43.0 (#3826) 2025-11-26 18:35:18 +01:00
Zeno Kapitein 0f4c53cfbd Update Assistant e2e tests (#3814) 2025-11-26 17:09:48 +00:00
Zeno Kapitein c156e946b0 Update Assistant styling (#3797) 2025-11-24 12:41:00 +01:00
Nolann B. 461e15fd00 Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme (#3820) 2025-11-22 10:44:05 +01:00
conico974 2989f79708 Add 'use server' directive to markdown renderer (#3815) 2025-11-20 09:28:00 +01:00
Nolann B. 344842f5ce Improve OpenAPI circular references (#3821) 2025-11-19 22:44:32 +01:00
Samy Pessé c51076efc4 Fix links to other spaces in embeddable view (#3819) 2025-11-19 10:38:53 +00:00
Zeno Kapitein 38e34dcce3 Optimise page outline (#3813) 2025-11-19 11:26:16 +01:00
Nolann B. 04a4175678 Remove hidden site spaces from navigation (#3795) 2025-11-18 22:49:41 +01:00
Nolann B. 3e40b4db50 Fix OpenAPI basic auth placeholder (#3816) 2025-11-18 20:18:59 +01:00
158 changed files with 4371 additions and 1935 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix missing properties in allOf/oneOf
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add Input component
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Enhance discriminator handling in OpenAPISchema
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix overflowing section groups
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search results cursor
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/embed": minor
"gitbook": patch
---
Improve Docs Embed with separate Assistant and Docs tabs
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": minor
---
Update to FontAwesome 7.1.0
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add customizable suggested questions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI basic auth placeholder
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces and root page in embeddable view.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support `greeting` and fix suggested questions if there are no custom ones defined
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI oneOf/allOf merge
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Remove hidden site spaces from navigation
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix hidden section not found
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI circular references
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,7 +71,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -70,7 +70,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
@@ -81,7 +81,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -104,7 +104,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+155 -51
View File
@@ -126,7 +126,7 @@
"@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.11.0",
"@opennextjs/cloudflare": "^1.14.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -143,6 +143,7 @@
"classnames": "catalog:",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -156,7 +157,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "15.4.0",
"next": "15.4.10",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -206,6 +207,7 @@
"env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"rss-parser": "^3.13.0",
"stylelint": "^16.16.0",
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
@@ -221,8 +223,8 @@
"gitbook-icons": "./bin/gitbook-icons.js",
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
},
"devDependencies": {
"@types/react": "catalog:",
@@ -232,7 +234,7 @@
"typescript": "catalog:",
},
"optionalDependencies": {
"@gitbook/fontawesome-pro": "1.0.15",
"@gitbook/fontawesome-pro": "1.0.16",
},
"peerDependencies": {
"react": "*",
@@ -344,7 +346,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.150.0",
"@gitbook/api": "0.154.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -354,8 +356,8 @@
"bidc": "^0.0.2",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1",
@@ -383,25 +385,25 @@
"@argos-ci/util": ["@argos-ci/util@2.3.2", "", {}, "sha512-xtNHJxpWYNst/sMNn4Jv/vkODuFsJ+APr4FBeoFUdIa+Izjl4ZFHsYA2PUyu+ygIpQCkof8yZLL9U1/VpiyyIw=="],
"@ast-grep/napi": ["@ast-grep/napi@0.35.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.35.0", "@ast-grep/napi-darwin-x64": "0.35.0", "@ast-grep/napi-linux-arm64-gnu": "0.35.0", "@ast-grep/napi-linux-arm64-musl": "0.35.0", "@ast-grep/napi-linux-x64-gnu": "0.35.0", "@ast-grep/napi-linux-x64-musl": "0.35.0", "@ast-grep/napi-win32-arm64-msvc": "0.35.0", "@ast-grep/napi-win32-ia32-msvc": "0.35.0", "@ast-grep/napi-win32-x64-msvc": "0.35.0" } }, "sha512-3ucaaSxV6fxXoqHrE/rxAvP1THnDdY5jNzGlnvx+JvnY9C/dSRKc0jlRMRz59N3El572+/yNRUUpAV1T9aBJug=="],
"@ast-grep/napi": ["@ast-grep/napi@0.40.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.0", "@ast-grep/napi-darwin-x64": "0.40.0", "@ast-grep/napi-linux-arm64-gnu": "0.40.0", "@ast-grep/napi-linux-arm64-musl": "0.40.0", "@ast-grep/napi-linux-x64-gnu": "0.40.0", "@ast-grep/napi-linux-x64-musl": "0.40.0", "@ast-grep/napi-win32-arm64-msvc": "0.40.0", "@ast-grep/napi-win32-ia32-msvc": "0.40.0", "@ast-grep/napi-win32-x64-msvc": "0.40.0" } }, "sha512-tq6nO/8KwUF/mHuk1ECaAOSOlz2OB/PmygnvprJzyAHGRVzdcffblaOOWe90M9sGz5MAasXoF+PTcayQj9TKKA=="],
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.35.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T+MN4Oinc+sXjXCIHzfxDDWY7r2pKgPxM6zVeVlkMTrJV2mJtyKYBIS+CABhRM6kflps2T2I6l4DGaKV/8Ym9w=="],
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZMjl5yLhKjxdwbqEEdMizgQdWH2NrWsM6Px+JuGErgCDe6Aedq9yurEPV7veybGdLVJQhOah6htlSflXxjHnYA=="],
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.35.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pEYiN6JI1HY2uWhMYJ9+3yIMyVYKuYdFzeD+dL7odA3qzK0o9N9AM3/NOt4ynU2EhufaWCJr0P5NoQ636qN6MQ=="],
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-f9Ol5oQKNRMBkvDtzBK1WiNn2/3eejF2Pn9xwTj7PhXuSFseedOspPYllxQo0gbwUlw/DJqGFTce/jarhR/rBw=="],
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NBuzQngABGKz7lhG08IQb+7nPqUx81Ol37xmS3ZhVSdSgM0mtp93rCbgFTkJcAFE8IMfCHQSg7G4g0Iotz4ABQ=="],
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tO+VW5GDhT9jGkKOK+3b8+ohKjC98WTzn7wSskd/myyhK3oYL1WTKqCm07WSYBZOJvb3z+WaX+wOUrc4bvtyQ=="],
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1EcvHPwyWpCL/96LuItBYGfeI5FaMTRvL+dHbO/hL5q1npqbb5qn+ppJwtNOjTPz8tayvgggxVk9T4C2O7taYA=="],
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A=="],
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-FDzNdlqmQnsiWXhnLxusw5AOfEcEM+5xtmrnAf3SBRFr86JyWD9qsynnFYC2pnP9hlMfifNH2TTmMpyGJW49Xw=="],
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA=="],
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wlmndjfBafT8u5p4DBnoRQyoCSGNuVSz7rT3TqhvlHcPzUouRWMn95epU9B1LNLyjXvr9xHeRjSktyCN28w57Q=="],
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ=="],
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.35.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-gkhJeYc4rrZLX2icLxalPikTLMR57DuIYLwLr9g+StHYXIsGHrbfrE6Nnbdd8Izfs34ArFCrcwdaMrGlvOPSeg=="],
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w=="],
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.35.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-OdUuRa3chHCZ65y+qALfkUjz0W0Eg21YZ9TyPquV5why07M6HAK38mmYGzLxFH6294SvRQhs+FA/rAfbKeH0jA=="],
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0JkdBZi5l9vZhGEO38A1way0LmLRDU5Vos6MXrLIOVkymmzDTDlCdY394J1LMmmsfwWcyJg6J7Yv2dw41MCxDQ=="],
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pcQRUHqbroTN1oQ56V982a7IZTUUySQYWa2KEyksiifHGuBuitlzcyzFGjT96ThcqD9XW0UVJMvpoF2Qjh006Q=="],
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
@@ -719,13 +721,13 @@
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
"@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.7.2", "", {}, "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg=="],
"@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.1.0", "", {}, "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.6.0", "", {}, "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.1.0", "", {}, "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA=="],
"@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@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.150.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-OjP52+BlBCo3BvKhLz5exR8uvzT9XaYuMACC72LGSsvyz7Ejiehs2JMrCmX3zQF1aJ+UemT6TTZCUx1T0PQyuQ=="],
"@gitbook/api": ["@gitbook/api@0.154.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-huEgWWa2H+H4ofYEAQl9r49u0gVjDHHWVNJKhd6SgGCfefjNPHkQj9zaky/5DBQzlu/+SDmutWiOtYdrykQO8w=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -739,7 +741,7 @@
"@gitbook/expr": ["@gitbook/expr@workspace:packages/expr"],
"@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.15", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^6.7.2" } }, "sha512-iiXDkwzvaRrRF853Vwjt8kWrd6yRJFb7BfhFyBNYvVmjVmxRfrvmstiOPUIdHS7jPaAWCWLzirhokwh8BFBTHw=="],
"@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.16", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^7.1.0" } }, "sha512-jypE3FpC5U7lealzpGVq1Jie5Ay5jYv/UkOLxPhOpffjCd98A+Ex08M6GyINMDO7KCBaF5p8pbdl693hf0DwNg=="],
"@gitbook/fonts": ["@gitbook/fonts@workspace:packages/fonts"],
@@ -821,6 +823,10 @@
"@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -867,23 +873,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@15.4.0-canary.57", "", {}, "sha512-gDNgJA7YnuUWQ2Ei6oeMiuSSWWICyYGK0AbWQnOfub1LIyYPNXMSyvyLTkGwVyIixbLMUFdzjTgU+ZxYHil7Mg=="],
"@next/env": ["@next/env@15.4.10", "", {}, "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tUq3IBxdu1I+CfIQ7fOVHb5L0Ehu9lXmA3bcVVMDswnKnmL3V15LInWkFbQ/kuIDv87AT+UfJB4us+yJ1lNr/A=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zsFozes7iXAkfQYQGYi9xiDKrS7mjLCMl9DIZfm0cEmilYmJrO16rn04H4Y+NJZ5kBxJUXn+hAy4kXmToZYFHA=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-z5S7BWDhBp+VJUzDEVpDasJC9EebZ1Jw8R25ryEH+ojLl14kMuoDtULOdin3f4zXBEwPvJrkt1G+Aes+TSX4iQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VqN0D+CdUxMSO+a1EaIEJajMKlHbsSIObd1zYiNJ3d5vWO8eg9K/f+AtBm6F4zaRlSLTURKLoZ+f6aGIbzhXFA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iWQKV+kCeg0iSQR0tYm8I5K0w7YJnvy3HsdIlYHAhHYiqFEr/3JkKL3p1KduxRiaMj3dCv5ZuyZuCGV1le89ew=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pm9t8LhfpNLtU5OjMny7OBjATRn3tiP+Kw4FQz/27XhgTmiwNbcqSAV4w+8TJ34e/X61xjj6vY32fsofxsQU5A=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yvx5Rj9Tb370IERMvHo9+gAxLQI6OIwmin0kELI8fvGYflaRz/cF1H2sk+a2y7WH3LY7RnKVRxoRLLVsch2WXQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-IldilrY7yfSlqOpTha7I7rYDDkyCC/KIMCjo2Nf0WqTLw3B5ha4islIF07YjNRbZS1Xl5WMqo9zZtx7VAE4epg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -919,9 +925,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@3.8.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-elpMb0fJZc0a1VtymedFa7P1lYcyOmt+Pwqyacpq2C/SvbETIeBlW/Xle/vY95ICtccKUxITI8MtonrCo2+2/Q=="],
"@opennextjs/aws": ["@opennextjs/aws@3.9.4", "", { "dependencies": { "@ast-grep/napi": "^0.40.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.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "< 14.2 || ^14.2 || 14.3.0-canary.0 - 14.3.0-canary.76 || ~15.0.5 || ~15.1.9 || ~15.2.6 || ~15.3.6 || ~15.4.8 || ~15.5.7 || ^16.0.7" }, "bin": { "open-next": "dist/index.js" } }, "sha512-i5gWKSP7QQGz59z85ZjOGtIfXK1ZgRLEwbt7/dkmuujhTuMBWygQqrb2EGRf8vRZH1Xvp0fYENUZx7NjgvxsIg=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.11.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.8.5", "@types/rclone.js": "^0.6.3", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "rclone.js": "^0.6.6", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.38.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-2QVZLjCr3hPwkcGEetLarTZvlNIHsGV3j2+i+KNsjFIBgvsnxhmRqJbT0u8J9cVMFKNh19hSOTOmUKs1T3DWig=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.14.4", "", { "dependencies": { "@ast-grep/napi": "0.40.0", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.4", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "14 - 14.2 || 14.3.0-canary.0 - 14.3.0-canary.76 || ~15.0.5 || ~15.1.9 || ~15.2.6 || ~15.3.6 || ~15.4.8 || ~15.5.7 || ^16.0.7", "wrangler": "^4.49.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-2dODhjsRsZog1VHxX9QwUkxq76aBwaEl+L67m8c47V6zZpIgAFODHMxtj5NPM3uz6pUiT4edYGNS5bR99ck3Jg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1537,8 +1543,6 @@
"@types/parse-cache-control": ["@types/parse-cache-control@1.0.4", "", {}, "sha512-YA9hcvRn3oYBdLK0PdWRsw5ehrqmfdCwfz68sSAxi2OB6L7B3jJEhrqlLfMvxnWfsZcUdbR6QI6NfkY1Y4e2zg=="],
"@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
@@ -1633,8 +1637,6 @@
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
@@ -1729,6 +1731,10 @@
"call-bind": ["call-bind@1.0.7", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.1" } }, "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001668", "", {}, "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw=="],
@@ -1877,6 +1883,8 @@
"dts-resolver": ["dts-resolver@2.1.2", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -1905,7 +1913,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"env-cmd": ["env-cmd@10.1.0", "", { "dependencies": { "commander": "^4.0.0", "cross-spawn": "^7.0.0" }, "bin": { "env-cmd": "bin/env-cmd.js" } }, "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA=="],
@@ -1921,6 +1929,8 @@
"es-module-lexer": ["es-module-lexer@1.4.1", "", {}, "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
@@ -2003,6 +2013,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
"file-entry-cache": ["file-entry-cache@10.0.7", "", { "dependencies": { "flat-cache": "^6.1.7" } }, "sha512-txsf5fu3anp2ff3+gOJJzRImtrtm/oa9tYLN0iTuINZ++EyVR/nRrg2fKYwvG/pXDofcrvvb0scEbX3NyW/COw=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
@@ -2021,7 +2033,7 @@
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
@@ -2061,6 +2073,8 @@
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
@@ -2069,7 +2083,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.0.1", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^4.0.1", "minimatch": "^10.0.0", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw=="],
"glob": ["glob@12.0.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2221,7 +2235,7 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@4.0.2", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw=="],
"jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="],
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
@@ -2351,6 +2365,8 @@
"markdown-table": ["markdown-table@3.0.3", "", {}, "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mathjax": ["mathjax@3.2.2", "", {}, "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw=="],
"mathml-tag-names": ["mathml-tag-names@2.1.3", "", {}, "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg=="],
@@ -2473,7 +2489,7 @@
"miniflare": ["miniflare@4.20251008.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251008.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-sKCNYNzXG6l8qg0Oo7y8WcDKcpbgw0qwZsxNpdZilFTR4EavRow2TlcwuPSVN99jqAjhz0M4VXvTdSGdtJ2VfQ=="],
"minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2501,7 +2517,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@15.4.0", "", { "dependencies": { "@next/env": "15.4.0-canary.57", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.0", "@next/swc-darwin-x64": "15.4.0", "@next/swc-linux-arm64-gnu": "15.4.0", "@next/swc-linux-arm64-musl": "15.4.0", "@next/swc-linux-x64-gnu": "15.4.0", "@next/swc-linux-x64-musl": "15.4.0", "@next/swc-win32-arm64-msvc": "15.4.0", "@next/swc-win32-x64-msvc": "15.4.0", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9JfEyg23carbb/AxUylRQh4zVN4dt1af9aJ+L3CFl/LKQIQkWUWN44YhB9M5JhxMjleF6rkfNOD7IqDcsb6zyQ=="],
"next": ["next@15.4.10", "", { "dependencies": { "@next/env": "15.4.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2659,8 +2675,6 @@
"raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="],
"rclone.js": ["rclone.js@0.6.6", "", { "dependencies": { "adm-zip": "^0.5.9", "mri": "^1.2.0" }, "os": [ "!aix", "!android", ], "cpu": [ "!ppc", "!ia32", "!s390", "!ppc64", "!s390x", ], "bin": { "rclone": "bin/rclone.js" } }, "sha512-Dxh34cab/fNjFq5SSm0fYLNkGzG2cQSBy782UW9WwxJCEiVO4cGXkvaXcNlgv817dK8K8PuQ+NHUqSAMMhWujQ=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-aria": ["react-aria@3.44.0", "", { "dependencies": { "@internationalized/string": "^3.2.7", "@react-aria/breadcrumbs": "^3.5.29", "@react-aria/button": "^3.14.2", "@react-aria/calendar": "^3.9.2", "@react-aria/checkbox": "^3.16.2", "@react-aria/color": "^3.1.2", "@react-aria/combobox": "^3.14.0", "@react-aria/datepicker": "^3.15.2", "@react-aria/dialog": "^3.5.31", "@react-aria/disclosure": "^3.1.0", "@react-aria/dnd": "^3.11.3", "@react-aria/focus": "^3.21.2", "@react-aria/gridlist": "^3.14.1", "@react-aria/i18n": "^3.12.13", "@react-aria/interactions": "^3.25.6", "@react-aria/label": "^3.7.22", "@react-aria/landmark": "^3.0.7", "@react-aria/link": "^3.8.6", "@react-aria/listbox": "^3.15.0", "@react-aria/menu": "^3.19.3", "@react-aria/meter": "^3.4.27", "@react-aria/numberfield": "^3.12.2", "@react-aria/overlays": "^3.30.0", "@react-aria/progress": "^3.4.27", "@react-aria/radio": "^3.12.2", "@react-aria/searchfield": "^3.8.9", "@react-aria/select": "^3.17.0", "@react-aria/selection": "^3.26.0", "@react-aria/separator": "^3.4.13", "@react-aria/slider": "^3.8.2", "@react-aria/ssr": "^3.9.10", "@react-aria/switch": "^3.7.8", "@react-aria/table": "^3.17.8", "@react-aria/tabs": "^3.10.8", "@react-aria/tag": "^3.7.2", "@react-aria/textfield": "^3.18.2", "@react-aria/toast": "^3.0.8", "@react-aria/tooltip": "^3.8.8", "@react-aria/tree": "^3.1.4", "@react-aria/utils": "^3.31.0", "@react-aria/visually-hidden": "^3.8.28", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-2Pq3GQxBgM4/2BlpKYXeaZ47a3tdIcYSW/AYvKgypE3XipxOdQMDG5Sr/NBn7zuJq+thzmtfRb0lB9bTbsmaRw=="],
@@ -2733,12 +2747,16 @@
"router": ["router@2.0.0", "", { "dependencies": { "array-flatten": "3.0.0", "is-promise": "4.0.0", "methods": "~1.1.2", "parseurl": "~1.3.3", "path-to-regexp": "^8.0.0", "setprototypeof": "1.2.0", "utils-merge": "1.0.1" } }, "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
@@ -2767,6 +2785,12 @@
"side-channel": ["side-channel@1.0.6", "", { "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" } }, "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
@@ -2953,7 +2977,7 @@
"url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
"urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="],
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
"use-callback-ref": ["use-callback-ref@1.3.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA=="],
@@ -3027,6 +3051,12 @@
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -3633,8 +3663,6 @@
"@floating-ui/vue/@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@fortawesome/fontawesome-svg-core/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.6.0", "", {}, "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw=="],
"@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -3687,6 +3715,12 @@
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@opennextjs/aws/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@opennextjs/aws/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -3995,8 +4029,6 @@
"@types/jsonwebtoken/@types/node": ["@types/node@20.16.11", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw=="],
"@types/rclone.js/@types/node": ["@types/node@22.18.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg=="],
"@types/swagger2openapi/@types/node": ["@types/node@20.16.11", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw=="],
"@types/ws/@types/node": ["@types/node@20.16.11", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw=="],
@@ -4057,6 +4089,8 @@
"bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="],
"call-bound/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
@@ -4079,6 +4113,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dunder-proto/gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"edge-runtime/async-listen": ["async-listen@3.0.1", "", {}, "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA=="],
"edge-runtime/picocolors": ["picocolors@1.0.0", "", {}, "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="],
@@ -4101,6 +4137,8 @@
"finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"foreground-child/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-minipass/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="],
@@ -4111,6 +4149,8 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"https-proxy-agent/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -4217,6 +4257,16 @@
"send/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"side-channel-list/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"side-channel-map/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"side-channel-map/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"side-channel-weakmap/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"side-channel-weakmap/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"simple-swizzle/is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
"spawndamnit/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -4761,6 +4811,20 @@
"@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@opennextjs/aws/express/body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"@opennextjs/aws/express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"@opennextjs/aws/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"@opennextjs/aws/express/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"@opennextjs/aws/express/router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"@opennextjs/aws/express/serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
"@opennextjs/aws/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -4963,6 +5027,12 @@
"bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"call-bound/get-intrinsic/es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"call-bound/get-intrinsic/gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"call-bound/get-intrinsic/has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
@@ -5051,6 +5121,8 @@
"remark-gfm/mdast-util-gfm/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="],
"rimraf/glob/foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -5059,6 +5131,18 @@
"send/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"side-channel-map/get-intrinsic/es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"side-channel-map/get-intrinsic/gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"side-channel-map/get-intrinsic/has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"side-channel-weakmap/get-intrinsic/es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"side-channel-weakmap/get-intrinsic/gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"side-channel-weakmap/get-intrinsic/has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"stylelint/postcss/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
@@ -5209,6 +5293,16 @@
"@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"@opennextjs/aws/express/body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@opennextjs/aws/express/qs/side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"@opennextjs/aws/express/router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"@opennextjs/aws/express/router/path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
"@opennextjs/aws/express/serve-static/send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -5295,6 +5389,12 @@
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@opennextjs/aws/express/body-parser/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"@opennextjs/aws/express/qs/side-channel/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"@opennextjs/aws/express/serve-static/send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"@scalar/draggable/vue/@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@scalar/draggable/vue/@vue/compiler-sfc/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
@@ -5320,5 +5420,9 @@
"@aws-sdk/middleware-sdk-sqs/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
"@aws-sdk/middleware-sdk-sqs/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
"@opennextjs/aws/express/body-parser/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"@opennextjs/aws/express/serve-static/send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
}
}
+3 -3
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.150.0",
"@gitbook/api": "0.154.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -49,8 +49,8 @@
"bidc": "^0.0.2",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
+370 -8
View File
@@ -1,24 +1,61 @@
# `@gitbook/embed`
# GitBook Docs Embed (`@gitbook/embed`)
Embed the GitBook Docs Assistant in your product or website.
Embed your GitBook docs in your product or website.
The Docs Embed can contain two tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **Docs**: A browser for navigating your documentation site
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
# Usage
## As a script from your docs site
## As a standalone script from your docs site
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script>
// Initialize with Authenticated Access (optional)
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show');
</script>
```
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
### Example: Configuring the widget
```javascript
GitBook('configure', {
button: {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
```
See the [Configuration](#configuration) section for all available options.
## As a package from NPM
Install the package: `npm install @gitbook/embed` and import it in your web application:
@@ -30,10 +67,46 @@ const gitbook = createGitBook({
siteURL: 'https://docs.company.com'
});
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL();
iframe.src = gitbook.getFrameURL({
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
userId: '123',
plan: 'premium'
}
}
});
// Create a frame client to communicate with the iframe
const frame = gitbook.createFrame(iframe);
// Use the frame client methods
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
frame.navigateToAssistant(); // Switch to the assistant tab
frame.postUserMessage('How do I get started?');
frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
// Listen to events
frame.on('close', () => {
console.log('Frame closed');
});
```
## As React components
@@ -41,9 +114,298 @@ const frame = gitbook.createFrame(iframe);
After installing the NPM package, you can import prebuilt React components:
```tsx
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookAssistantFrame />
<GitBookFrame
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
]}
tools={[/* ... */]}
/>
</GitBookProvider>
```
You can also use the `useGitBook` hook to access the client:
```tsx
import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
# API Reference
## Method Comparison
| Method | Standalone Script | NPM Package | React Components |
|--------|------------------|-------------|------------------|
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
## Method Signatures
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
- `GitBook('close')` - Close widget window
- `GitBook('toggle')` - Toggle widget window
- `GitBook('navigateToPage', path: string)` - Navigate to page
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
- `GitBook('postUserMessage', message: string)` - Post message to chat
- `GitBook('clearChat')` - Clear chat history
- `GitBook('configure', settings: {...})` - Configure widget
- `GitBook('unload')` - Unload widget
### NPM Package
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
- `frame.navigateToPage(path: string)``void`
- `frame.navigateToAssistant()``void`
- `frame.postUserMessage(message: string)``void`
- `frame.clearChat()``void`
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)``void`
- `frame.on(event: string, listener: Function)``() => void` (unsubscribe)
### React Components
**Components:**
- `<GitBookProvider siteURL: string>` - Provider component
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
**Hooks:**
- `useGitBook()``GitBookClient` (must be used within `<GitBookProvider>`)
# Configuration
Configuration options are available across usage methods as follows:
- **Standalone script**: via `GitBook('configure', {...})`
- **NPM package**: via `frame.configure({...})`
- **React components**: via props on `<GitBookFrame>`
### `tabs`
Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `actions`
Available in: Standalone script, NPM package, React components
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
- **Type**: `GitBookEmbeddableActionDefinition[]`
- **Properties**:
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
- `label`: `string` - Button label text
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
```javascript
actions: [
{
icon: 'comments',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
},
{
icon: 'rocket',
label: 'Get started',
onClick: () => {
GitBook('navigateToPage', '/getting-started');
}
}
]
```
### `greeting`
Available in: Standalone script, NPM package, React components
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
- **Type**: `{ title: string, subtitle: string }`
```javascript
greeting: {
title: 'Welcome!',
subtitle: 'How can I help you today?'
}
```
### `suggestions`
Available in: Standalone script, NPM package, React components
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
- **Type**: `string[]`
```javascript
suggestions: [
'What is GitBook?',
'How do I get started?',
'What can you do?'
]
```
### `tools`
Available in: Standalone script, NPM package, React components
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
- **Type**: `GitBookToolDefinition[]`
- **Properties**:
- `name`: `string` - Unique tool identifier
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
```javascript
tools: [
{
name: 'get_user_info',
description: 'Get information about the current user',
inputSchema: {
type: 'object',
properties: {
userId: {
type: 'string',
description: 'The user ID to look up'
}
},
required: ['userId']
},
execute: async (input) => {
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
return {
output: { name: user.name, plan: user.plan },
summary: `Retrieved info for user ${user.name}`
};
}
},
{
name: 'create_ticket',
description: 'Create a support ticket',
confirmation: {
icon: 'circle-question',
label: 'Create support ticket?'
},
inputSchema: {
type: 'object',
properties: {
subject: { type: 'string' },
description: { type: 'string' }
},
required: ['subject', 'description']
},
execute: async (input) => {
const ticket = await fetch('/api/tickets', {
method: 'POST',
body: JSON.stringify(input)
}).then(r => r.json());
return {
output: { ticketId: ticket.id },
summary: `Created ticket #${ticket.id}`
};
}
}
]
```
### `visitor` (Authenticated Access)
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ visitor: {...} })`
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
- **Properties**:
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
```javascript
visitor: {
token: 'your-jwt-token',
unsignedClaims: {
userId: '123',
plan: 'premium',
role: 'admin'
}
}
```
### `button`
Available in: Standalone script only
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
- **Properties**:
- `label`: `string` - Button label text
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
```javascript
button: {
label: 'Ask',
icon: 'assistant'
}
```
+1 -1
View File
@@ -40,7 +40,7 @@ export function createGitBook(options: CreateGitBookOptions) {
const client: GitBookClient = {
getFrameURL: (frameOptions) => {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.visitor?.token) {
url.searchParams.set('token', frameOptions.visitor.token);
@@ -64,8 +64,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
tabs: ['assistant', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
};
+15 -5
View File
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
/**
* Custom button definition to be passed to the embeddable GitBook.
*/
export type GitBookEmbeddableButtonDefinition = {
export type GitBookEmbeddableActionDefinition = {
/**
* Icon to be displayed in the button.
*/
@@ -41,16 +41,26 @@ export type GitBookEmbeddableButtonDefinition = {
};
/**
* Overall configuration for the layout of the embeddable GitBook.
* Overall configuration for the layout of the GitBook embed.
*/
export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
/**
* Buttons to be displayed in the header of the embeddable GitBook.
* Additional buttons to be displayed in the header of the GitBook embed.
* @deprecated Use `actions` instead.
*/
buttons: GitBookEmbeddableButtonDefinition[];
buttons?: GitBookEmbeddableActionDefinition[];
/** Message to be displayed in the welcome page. */
welcomeMessage: string;
greeting: {
title: string;
subtitle: string;
};
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
+5 -4
View File
@@ -17,7 +17,7 @@ export type GitBookFrameProps = {
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const { className, visitor, actions, greeting, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
@@ -33,12 +33,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
buttons,
welcomeMessage,
tabs: ['assistant', 'docs'],
actions,
greeting,
suggestions,
tools,
});
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
}, [gitbookFrame, actions, greeting, suggestions, tools]);
return (
<iframe
+47 -28
View File
@@ -31,24 +31,50 @@ type StandaloneCalls =
// Clear the chat
| ['clearChat']
// Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration>]
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
// Navigate to a page
| ['navigateToPage', string]
// Navigate to the assistant
| ['navigateToAssistant'];
type StandaloneConfiguration = {
/** Configure the button to open the embed */
button: {
/** Label to be displayed in the button. */
label: string;
/** Icon to be displayed in the button. */
icon: 'assistant' | 'sparkle' | 'help' | 'book';
};
};
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
q?: StandaloneCalls[];
};
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
icon: 'assistant',
},
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
};
const widgetButton = document.createElement('button');
widgetButton.id = 'gitbook-widget-button';
widgetButton.addEventListener('click', () => {
GitBook('toggle');
});
widgetButton.innerHTML = `
<span id="gitbook-widget-button-icon"></span>
<span id="gitbook-widget-button-label">Ask</span>
<span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span>
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
`;
const widgetWindow = document.createElement('div');
@@ -58,17 +84,6 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() {
if (!_client) {
throw new Error(
@@ -135,27 +150,31 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]);
break;
case 'configure':
case 'configure': {
const settings = args[1];
frameConfiguration = {
...frameConfiguration,
...args[1],
...settings,
};
// Update the button label and icon
if (settings.button?.label) {
const label = widgetButton.querySelector('#gitbook-widget-button-label');
if (label) {
label.textContent = settings.button.label;
}
}
if (settings.button?.icon) {
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
if (icon) {
icon.setAttribute('data-icon', settings.button.icon);
}
}
getIframe().frame.configure({
...frameConfiguration,
buttons: [
...frameConfiguration.buttons,
// Always include a close button
{
icon: 'close',
label: 'Close',
onClick: () => {
GitBook('close');
},
},
],
});
break;
}
case 'clearChat':
getIframe().frame.clearChat();
break;
+24 -1
View File
@@ -29,6 +29,16 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
* {
box-sizing: border-box;
}
@@ -103,6 +113,18 @@
background-color: currentColor;
}
#gitbook-widget-button-icon[data-icon="sparkle"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="help"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="book"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
}
#gitbook-widget-button.open #gitbook-widget-button-icon {
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
}
@@ -120,7 +142,8 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-solid);
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
border-radius: var(--gitbook-widget-radius);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+77 -40
View File
@@ -37,6 +37,31 @@ import {
waitForNotFound,
} from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-time-greeting"]');
if (greeting) {
greeting.textContent = 'Good morning';
}
};
const overrideAIResponse = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
userMessage.textContent = '[Replaced message] Chat message sent by the user';
}
const assistantMessage = document.querySelectorAll(
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
);
assistantMessage.forEach((message) => {
message.innerHTML = '[Replaced message] AI chat response';
});
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
};
const searchTestCases: Test[] = [
{
name: 'Search - AI Mode: None - Complete flow',
@@ -53,7 +78,9 @@ const searchTestCases: Test[] = [
// Fill search input, expecting search results
await searchInput.fill('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
await expect(page.getByTestId('search-results')).toBeVisible({
timeout: 10_000,
});
const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
@@ -98,9 +125,6 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Search - AI Mode: Search - Complete flow
// - Search - AI Mode: Search - URL query (Initial)
{
name: 'Search - AI Mode: Search - URL query (Results)',
url: `${getCustomizationURL({
@@ -108,16 +132,12 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Search,
},
})}&q=gitbook`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Ask - AI Mode: Search - URL query (Ask initial)
// - Ask - AI Mode: Search - URL query (Ask results)
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
@@ -125,25 +145,32 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions
await searchInput.focus();
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
// await expect(page.getByTestId('search-results')).toBeVisible();
// const recommendedQuestions = await page
// .getByTestId('search-recommended-question')
// .all();
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
await expect(page.getByTestId('search-results')).toBeVisible({
timeout: 30_000,
});
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option
await searchInput.fill('What is gitbook?');
await searchInput.fill(AI_PROMPT);
const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible();
await aiSearchResult.click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
},
},
{
@@ -153,27 +180,30 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
},
{
name: 'Ask - AI Mode: Assistant - Button',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
},
// {
// name: 'Ask - AI Mode: Assistant - Button',
// url: getCustomizationURL({
// ai: {
// mode: CustomizationAIMode.Assistant,
// },
// }),
// screenshot: false,
// run: async (page) => {
// await page.getByTestId('ai-chat-button').click();
// await expect(page.getByTestId('ai-chat')).toBeVisible();
// await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// },
// },
{
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
url: `${getCustomizationURL({
@@ -181,12 +211,13 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
})}&ask=`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
},
{
@@ -195,17 +226,18 @@ const searchTestCases: Test[] = [
ai: {
mode: CustomizationAIMode.Assistant,
},
})}&ask=What+is+GitBook%3F`,
screenshot: false,
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible({
timeout: 15_000,
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
'What is GitBook?'
);
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
},
},
];
@@ -880,6 +912,11 @@ const testCases: TestsCase[] = [
url: 'blocks/cards',
fullPage: true,
},
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{
name: 'Math',
url: 'blocks/math',
+6 -4
View File
@@ -8,9 +8,9 @@
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/embed": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*",
"@gitbook/embed": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -19,7 +19,7 @@
"@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.11.0",
"@opennextjs/cloudflare": "^1.14.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -36,6 +36,7 @@
"classnames": "catalog:",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -49,7 +50,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "15.4.0",
"next": "15.4.10",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -104,7 +105,8 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^39.3.0",
"wrangler": "^4.43.0"
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
@@ -16,7 +16,7 @@ export default async function Page(props: PageProps) {
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />;
return <SitePage context={context} pageParams={{ pathname }} staticRoute={false} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -22,7 +22,12 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers());
return (
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
@@ -1,16 +1,28 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
export const dynamic = 'force-static';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static';
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
return <EmbeddableAssistantPage context={context} />;
// If the assistant is not enabled, redirect to the docs
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
}
@@ -0,0 +1,21 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -12,7 +12,7 @@ export default async function Page(props: PageProps) {
const { context } = await getEmbeddableDynamicContext(params);
const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute={false} />;
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />;
return <SitePage context={context} pageParams={{ pathname }} staticRoute />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -19,7 +19,11 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout context={context}>
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<SiteLayout
context={context}
withTracking={withTracking}
@@ -1,16 +1,28 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { RouteParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
return <EmbeddableAssistantPage context={context} />;
// If the assistant is not enabled, redirect to the docs
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
}
@@ -0,0 +1,23 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -14,7 +14,7 @@ export default async function Page(props: PageProps) {
const { context } = await getEmbeddableStaticContext(params);
const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute />;
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
@@ -0,0 +1,12 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { servePageRSS } from '@/routes/rss';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageRSS(context, pathname);
}
+28 -8
View File
@@ -3,7 +3,7 @@ import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@
import { getDynamicCustomizationSettings } from '@/lib/customization';
import type { SiteAPIToken } from '@gitbook/api';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import { forbidden, notFound } from 'next/navigation';
import rison from 'rison';
export type RouteParamMode = 'url-host' | 'url';
@@ -80,14 +80,27 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
* Get the decoded page path from the params.
*/
export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
// If decoding the param fails, return a 404 instead of crashing
try {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
} catch (error) {
console.error(
`Returning 404 after failing to decode page path ${params.pagePath}: ${error}`
);
notFound();
}
}
function getSiteURLFromParams(params: RouteLayoutParams) {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL(`https://${decoded}`);
return url;
try {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL(`https://${decoded}`);
return url;
} catch (error) {
console.error(`Returning 404 after failing to decode site URL ${params.siteURL}: ${error}`);
notFound();
}
}
function getModeFromParams(mode: string): RouteParamMode {
@@ -102,6 +115,13 @@ function getModeFromParams(mode: string): RouteParamMode {
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
try {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
} catch (error) {
console.error(
`Returning 404 after failing to decode site data ${params.siteData}: ${error}`
);
notFound();
}
}
@@ -16,14 +16,14 @@ export function AIMessageView(
) {
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return (
return message.steps.length > 0 ? (
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
return (
<div
key={index}
className={tcls(
'flex animate-fade-in-slow flex-col gap-2',
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
@@ -35,7 +35,7 @@ export function AIMessageView(
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="mt-2 space-y-4 empty:hidden"
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
/>
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
@@ -45,5 +45,5 @@ export function AIMessageView(
);
})}
</div>
);
) : null;
}
@@ -39,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;
return (
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -160,7 +160,7 @@ async function DescriptionForSearchToolCall(props: {
const hasResults = toolCall.results.length > 0;
return (
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<details className="-ml-5 group flex w-full flex-col">
<summary
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',
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null}
</summary>
{hasResults ? (
<div className="hide-scrollbar 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 transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
@@ -128,10 +128,10 @@ function parseResponse<T>(
parse: (response: AIStreamResponse) => T | undefined | Promise<T | undefined>
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
response: Promise<{ responseId: string | null }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
let resolveResponse: (value: { responseId: string | null }) => void;
const response = new Promise<{ responseId: string | null }>((resolve) => {
resolveResponse = resolve;
});
@@ -147,7 +147,7 @@ function parseResponse<T>(
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
resolveResponse({ responseId: event.response.id ?? null });
}
}
+11 -2
View File
@@ -15,7 +15,12 @@ import { useSearch } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean;
greeting?: {
title: string;
subtitle: string;
};
};
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
@@ -49,8 +54,11 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, children } = props;
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
const { aiMode, trademark, suggestions, greeting, children } = props;
const value = React.useMemo(
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
@@ -88,6 +96,7 @@ export function useAI(): AIContext {
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-4"
/>
),
open: (query?: string) => {
@@ -87,6 +87,19 @@ export type AIChatState = {
error: boolean;
};
export type AIChatEvent =
| { type: 'open' }
| { type: 'postMessage'; message: string }
| { type: 'clear' }
| { type: 'close' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
'type'
>;
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
@@ -96,6 +109,11 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void;
/** Clear the conversation */
clear: () => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => () => void;
};
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
@@ -123,6 +141,17 @@ export function useAIChatState(): AIChatState {
return state;
}
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/**
* Provide the controller to interact with the AI chat.
*/
@@ -137,6 +166,9 @@ export function AIChatProvider(props: {
const [, setSearchState] = useSearch();
const language = useLanguage();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
@@ -149,6 +181,8 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
@@ -162,6 +196,8 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default',
open: false,
}));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]);
// Stream a message with the AI backend
@@ -260,7 +296,7 @@ export function AIChatProvider(props: {
case 'response_finish': {
globalState.setState((state) => ({
...state,
responseId: event.responseId,
responseId: event.response.id ?? null,
// Mark as not loading when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
loading: false,
@@ -379,8 +415,14 @@ export function AIChatProvider(props: {
}));
}
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message) {
// Return early if the message is the same as the previous message
globalState.setState((state) => ({
...state,
opened: true,
}));
return;
}
@@ -440,14 +482,34 @@ export function AIChatProvider(props: {
}));
}, [setSearchState]);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
on: onEvent,
};
}, [onOpen, onClose, onClear, onPostMessage]);
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
return (
<AIChatControllerContext.Provider value={controller}>
+124 -120
View File
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
useAI,
useAIChatController,
useAIChatState,
} from '../AI';
@@ -18,21 +19,23 @@ import {
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSubtitle,
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
export function AIChat(props: { trademark: boolean }) {
const { trademark } = props;
export function AIChat() {
const { config } = useAI();
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
@@ -67,41 +70,54 @@ export function AIChat(props: { trademark: boolean }) {
}, [chat.opened, trackEvent]);
return (
<div
<SideSheet
side="right"
open={chat.opened}
onOpenChange={(open) => {
if (open) {
chatController.open();
} else {
chatController.close();
}
}}
data-testid="ai-chat"
withScrim={true}
className={tcls(
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 depth-flat:lg:p-0 lg:pr-4 lg:pl-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 circular-corners:rounded-3xl rounded-corners:rounded-md border border-tint-subtle depth-subtle:shadow-lg shadow-tint transition-all duration-300 lg:w-76 depth-flat:lg:rounded-none depth-flat:lg:border-y-0 depth-flat:lg:border-r-0 xl:w-92">
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<Button
onClick={() => chatController.close()}
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<Button
onClick={() => chatController.close()}
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
</EmbeddableFrameBody>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
</div>
</SideSheet>
);
}
@@ -110,13 +126,14 @@ export function AIChat(props: { trademark: boolean }) {
*/
export function AIChatDynamicIcon(props: {
trademark: boolean;
className?: string;
}) {
const { trademark } = props;
const { trademark, className } = props;
const chat = useAIChatState();
return (
<AIChatIcon
className="size-5 text-tint"
className={tcls('size-5 text-tint', className)}
trademark={trademark}
state={
chat.error
@@ -145,10 +162,33 @@ export function AIChatSubtitle(props: {
const language = useLanguage();
return (
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
{chat.messages[chat.messages.length - 1]?.content
? tString(language, 'ai_chat_working')
: tString(language, 'ai_chat_thinking')}
<EmbeddableFrameSubtitle
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
</EmbeddableFrameSubtitle>
);
}
@@ -159,20 +199,16 @@ export function AIChatSubtitle(props: {
export function AIChatBody(props: {
chatController: AIChatController;
chat: AIChatState;
trademark: boolean;
welcomeMessage?: string;
suggestions?: string[];
greeting?: {
title: string;
subtitle: string;
};
}) {
const { chatController, chat, trademark, suggestions } = props;
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const [input, setInput] = React.useState('');
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
// Ref for the last user message element
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLDivElement>(null);
const [inputHeight, setInputHeight] = React.useState(0);
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -186,67 +222,44 @@ export function AIChatBody(props: {
return tString(language, 'ai_chat_assistant_greeting_evening');
}, [now, language]);
// Auto-scroll to the latest user message when messages change
React.useEffect(() => {
if (chat.messages.length > 0 && lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [chat.messages.length]);
React.useEffect(() => {
const timeout = setTimeout(() => {
if (lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setInputHeight(entry.contentRect.height + 32);
});
});
if (inputRef.current) {
observer.observe(inputRef.current);
}
return () => {
observer.disconnect();
clearTimeout(timeout);
};
}, []);
return (
<>
<div
ref={scrollContainerRef}
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
style={{
paddingBottom: `${inputHeight}px`,
}}
<ScrollContainer
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
<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-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
<AIChatIcon
state="intro"
trademark={trademark}
className="size-16 animate-[present_500ms_200ms_both]"
/>
</div>
<div className="animate-[fadeIn_500ms_400ms_both]">
<h5 className=" text-center font-bold text-lg text-tint-strong">
{timeGreeting}
</h5>
<p className="text-center text-tint">
{t(language, 'ai_chat_assistant_description')}
</p>
<div className="flex grow flex-col">
<div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col">
<div
className="flex size-16 shrink-0 animate-scale-in items-center justify-center rounded-full bg-primary-solid/1 [@container(min-height:400px)]:size-32"
style={{ animationDelay: '.3s' }}
>
<AIChatIcon
state="intro"
trademark={trademark}
className="size-8 text-primary [@container(min-height:400px)]:size-16"
/>
</div>
<div className="flex flex-col items-start gap-1 [@container(min-height:400px)]:items-center">
<h5
className="animate-blur-in-slow font-bold text-lg text-tint-strong leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-time-greeting"
>
{greeting?.title || timeGreeting}
</h5>
<p
className="animate-blur-in-slow text-tint leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.6s' }}
>
{greeting?.subtitle ||
t(language, 'ai_chat_assistant_description')}
</p>
</div>
</div>
{!chat.error ? (
<AIChatSuggestedQuestions
@@ -256,28 +269,19 @@ export function AIChatBody(props: {
) : null}
</div>
) : (
<AIChatMessages
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
<AIChatMessages chat={chat} chatController={chatController} />
)}
</div>
<div
ref={inputRef}
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
>
</ScrollContainer>
<div className="flex flex-col gap-2 px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput
value={input}
onChange={setInput}
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={() => {
chatController.postMessage({ message: input });
setInput('');
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
</div>
@@ -1,10 +1,9 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { t } from '@/intl/translate';
import { useAIChatController, useAIChatState } from '../AI';
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
import { Button } from '../primitives';
/**
* Button to control the chat (clear, etc.)
@@ -15,26 +14,16 @@ export function AIChatControlButton() {
const chatController = useAIChatController();
return chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<DropdownMenuItem
onClick={() => {
chatController.clear();
}}
>
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
{t(language, 'ai_chat_clear_conversation')}
</DropdownMenuItem>
</DropdownMenu>
<Button
onClick={() => {
chatController.clear();
}}
iconOnly
icon="trash-can"
label={t(language, 'ai_chat_clear_conversation')}
variant="blank"
size="default"
className="animate-blur-in-slow"
/>
) : null;
}
@@ -1,4 +1,3 @@
import { tcls } from '@/lib/tailwind';
import { Icon, IconStyle } from '@gitbook/icons';
import type React from 'react';
@@ -21,12 +20,18 @@ export function AIChatIcon({
<Icon
icon="sparkle"
{...props}
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)]'
)}
className={className}
style={{
animation: {
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
/>
);
}
@@ -50,28 +55,36 @@ export function AIChatIcon({
<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"
stroke="currentColor"
strokeWidth="1.2"
strokeWidth={state === 'intro' ? '1.2' : '1'}
strokeLinejoin="round"
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 === 'confirm' && 'animate-[fadeOut_.5s_both]',
state === 'default' && 'animate-[fadeIn_0s_both]',
state === 'error' && 'hidden'
)}
style={{ transformOrigin: '13px 3.5px' }}
shapeRendering="crispEdges"
className="transition-opacity duration-300"
style={{
animation: {
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
default: '',
error: '',
}[state],
transitionDelay:
state === 'default' || state === 'thinking' ? '.3s' : undefined,
opacity: ['done', 'confirm', 'error'].includes(state) ? 0 : 1,
transformOrigin: '13px 3.5px',
}}
/>
{/* Error */}
<g
clipPath="url(#clip0_153_2034)"
className={tcls(
'text-danger-subtle',
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
)}
className="transition-opacity duration-300"
style={{
color: 'rgb(var(--danger-9))',
opacity: state === 'error' ? 1 : 0,
transitionDelay: state === 'error' ? '.3s' : undefined,
}}
>
<path
d="M13.0312 1.42059L13.0312 3.95184"
@@ -94,29 +107,34 @@ export function AIChatIcon({
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
className={tcls(
state === 'done'
? 'animate-[fadeIn_.5s_.3s_both]'
: 'animate-[fadeOut_.5s_both]',
state === 'intro' && 'hidden',
state === 'confirm' && 'hidden'
)}
className="transition-opacity duration-300"
style={{
opacity: state === 'done' ? 1 : 0,
transitionDelay: state === 'done' ? '.3s' : undefined,
}}
/>
{/* Confirm */}
<path
className={tcls(
'fill-primary-9',
state === 'confirm'
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
: state === 'thinking'
? 'animate-[fadeOut_.5s_both]'
: 'hidden'
)}
className="transition-opacity duration-300"
style={{
fill: 'rgb(var(--primary-9))',
opacity: state === 'confirm' ? 1 : 0,
transitionDelay: state === 'confirm' ? '.3s' : undefined,
animation: {
intro: '',
thinking: '',
working: '',
done: '',
confirm: 'bounceSmall 1s infinite both',
default: '',
error: '',
}[state],
}}
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
/>
{/* Background */}
{/* 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"
@@ -124,7 +142,17 @@ export function AIChatIcon({
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
style={{
animation: {
intro: 'fadeIn 2s forwards',
thinking: '',
working: '',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
/>
{/* Logo */}
@@ -154,45 +182,65 @@ export function AIChatIcon({
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]'
)}
style={{
animation: {
intro: 'pathEnter 1.5s both ease-out',
thinking: 'pathLoading 2s infinite both',
working: 'pathLoading 2s infinite both',
done: 'pathEnter 1s forwards ease',
confirm: '',
default: '',
error: '',
}[state],
}}
/>
</g>
</svg>
);
}
export function AISearchIcon({
className = 'size-4',
state = 'default',
}: Pick<AIChatIconProps, 'className' | 'state'>) {
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
}
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
return (
<div
className={tcls(
'relative',
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
state === 'thinking' || state === 'working'
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
: ''
)}
style={{
position: 'relative',
animation: {
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
thinking:
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
working: '',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
>
<Icon icon="search" className={className} />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className={tcls(
'absolute top-[15.7%] left-[15.6%] size-[50%]',
state === 'thinking' || state === 'working'
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
: '',
state === 'intro'
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
: ''
)}
style={{
position: 'absolute',
top: '15.7%',
left: '15.6%',
width: '50%',
height: '50%',
animation: {
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
/>
</div>
);
@@ -1,38 +1,28 @@
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { useAIChatState } from '../AI/useAIChat';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { Input } from '../primitives/Input';
export function AIChatInput(props: {
value: string;
disabled?: boolean;
/**
* When true, the input is disabled
*/
loading: boolean;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
}) {
const { value, onChange, onSubmit, disabled, loading } = props;
const { onSubmit, disabled, loading } = props;
const language = useLanguage();
const chat = useAIChatState();
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = event.currentTarget;
onChange(textarea.value);
// Auto-resize
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
};
useEffect(() => {
if (!disabled && !loading) {
if (chat.opened && !disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -41,7 +31,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, loading]);
}, [disabled, loading, chat.opened]);
useHotkeys(
'mod+i',
@@ -55,58 +45,34 @@ export function AIChatInput(props: {
);
return (
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<textarea
ref={inputRef}
disabled={disabled || loading}
data-loading={loading}
data-testid="ai-chat-input"
className={tcls(
'resize-none',
'focus:outline-hidden',
'focus:ring-0',
'w-full',
'px-3',
'py-3',
'pb-12',
'h-auto',
'bg-transparent',
'peer',
'max-h-64',
'placeholder:text-tint/8',
'transition-colors',
'disabled:bg-tint-subtle',
'delay-300',
'disabled:delay-0',
'disabled:cursor-not-allowed',
'data-[loading=true]:cursor-progress',
'data-[loading=true]:opacity-50'
)}
value={value}
rows={1}
placeholder={tString(language, 'ai_chat_input_placeholder')}
onChange={handleInput}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
event.preventDefault();
event.currentTarget.style.height = 'auto';
onSubmit(value);
}
}}
/>
{!disabled ? (
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<HoverCardRoot>
<Input
data-testid="ai-chat-input"
name="ai-chat-input"
multiline
resize
sizing="large"
label="Assistant chat input"
placeholder={tString(language, 'ai_chat_input_placeholder')}
onSubmit={(val) => onSubmit(val as string)}
submitButton={{
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading}
aria-busy={loading}
ref={inputRef}
trailing={
<HoverCardRoot openDelay={500}>
<HoverCard
className="max-w-xs bg-tint p-2 text-sm text-tint"
arrow={{ className: 'fill-tint-3' }}
@@ -133,7 +99,8 @@ export function AIChatInput(props: {
</div>
</HoverCard>
<HoverCardTrigger>
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
{/* Negative margin to compensate for Input's padding, so the badge appears flush with the cursor */}
<div className="-ml-1 flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
{t(language, 'ai_chat_context_badge')}
</span>{' '}
@@ -144,14 +111,7 @@ export function AIChatInput(props: {
</div>
</HoverCardTrigger>
</HoverCardRoot>
<Button
label={tString(language, 'send')}
size="medium"
className="ml-auto"
disabled={disabled || !value.trim()}
onClick={() => onSubmit(value)}
/>
</div>
</div>
}
/>
);
}
@@ -2,7 +2,6 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback';
@@ -11,86 +10,124 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: {
chat: AIChatState;
chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement | null>;
}) {
const { chat, chatController, lastUserMessageRef } = props;
const { chat, chatController } = props;
return (
<>
{chat.messages.map((message, index) => {
const isLastMessage = index === chat.messages.length - 1;
const isLastUserMessage =
message.role === AIMessageRole.User &&
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type Message = (typeof chat.messages)[0];
type MessageGroup = { message: Message; originalIndex: number };
const messageGroups: Array<Array<MessageGroup>> = [];
let currentGroup: Array<MessageGroup> = [];
return (
<div
ref={isLastUserMessage ? lastUserMessageRef : undefined}
data-testid="ai-chat-message"
className={tcls(
message.content ? 'animate-fade-in-slow' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6',
'break-words',
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'text-tint-strong'
)}
style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}}
key={index}
>
{message.content ? message.content : null}
chat.messages.forEach((message, index) => {
if (message.role === AIMessageRole.User) {
// Start a new group
if (currentGroup.length > 0) {
messageGroups.push(currentGroup);
}
currentGroup = [{ message, originalIndex: index }];
} else {
// Add to current group
currentGroup.push({ message, originalIndex: index });
}
});
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
</div>
) : null}
// Add the last group if it exists
if (currentGroup.length > 0) {
messageGroups.push(currentGroup);
}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
return (
<div
key={groupIndex}
id={`message-group-${groupIndex}`}
className={tcls(
'flex flex-col gap-2 pt-2',
isLastGroup ? 'shrink-0 basis-full' : '',
'transition-discrete'
)}
style={{ animationDelay: '.2s' }}
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
return (
<div
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
? 'ai-chat-message-user'
: 'ai-chat-message-assistant'
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-6',
'break-words',
'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong'
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.content}
{isLastMessage && message.role === AIMessageRole.Assistant ? (
<div
className={tcls(
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
chat.loading ? '' : 'hidden opacity-0'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</div>
);
})}
</>
);
</>
) : null}
</div>
);
})}
</div>
);
});
}
export function HoldMessage({
breakLines = false,
className = '',
className,
}: { breakLines?: boolean; className?: string }) {
const language = useLanguage();
return (
<div
className={tcls(
'animate-[heightIn_500ms_4500ms_ease_both] overflow-hidden py-2 text-tint-subtle',
'animate-[heightIn_.5s_5s_ease_both] overflow-hidden py-2 text-tint-subtle [&.hidden]:animate-[heightOut_1s_ease-in_both]',
className
)}
>
@@ -99,7 +136,7 @@ export function HoldMessage({
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
className="animate-blur-in-slow"
style={{
animationDelay: `${5000 + index * 200}ms`,
}}
@@ -113,7 +150,7 @@ export function HoldMessage({
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
className="animate-blur-in-slow"
style={{
animationDelay: `${10000 + index * 200}ms`,
}}
@@ -131,9 +168,9 @@ function LoadingSkeleton() {
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
className="h-4 animate-[blurIn_500ms_ease-out_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2"
style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
@@ -7,25 +7,27 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[];
}) {
const language = useLanguage();
const {
chatController,
suggestions = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
],
} = props;
const { chatController, suggestions: _suggestions } = props;
const suggestions =
_suggestions && _suggestions.length > 0
? _suggestions
: [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
return (
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-start gap-2 self-start">
{suggestions.map((question, index) => (
<Button
key={question}
variant="secondary"
variant="blank"
size="medium"
className="max-w-full animate-[present_500ms_both] whitespace-normal"
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover"
style={{
animationDelay: `${800 + index * 100}ms`,
animationDelay: `${1000 + index * 100}ms`,
}}
onClick={() => chatController.postMessage({ message: question })}
>
@@ -29,7 +29,7 @@ export function AIChatToolConfirmations(props: {
);
return (
<div className="flex w-full flex-wrap justify-end gap-2">
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
{chat.pendingTools.map((tool, index) => (
<div
className="flex w-full animate-present-slow flex-col gap-1"
@@ -24,7 +24,9 @@ export function AIResponseFeedback(props: {
};
return (
<div className={tcls('flex h-fit items-center', className)}>
<div
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
>
<Button
icon="thumbs-up"
iconOnly
@@ -35,7 +37,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
)}
size="medium"
style={{ animationDuration: '.5s' }}
style={{ animationDelay: '.5s', animationDuration: '.5s' }}
onClick={() => handleRating(1)}
disabled={rating !== null}
active={rating === 1}
@@ -51,7 +53,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
)}
size="medium"
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
style={{ animationDelay: '.7s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)}
disabled={rating !== null}
active={rating === -1}
@@ -59,8 +61,8 @@ export function AIResponseFeedback(props: {
/>
{rating !== null ? (
<span
className="ml-2 animate-fade-in-slow text-tint-subtle"
style={{ animationDelay: '.3s' }}
className="ml-2 animate-blur-in-slow text-tint-subtle"
style={{ animationDelay: '.5s' }}
>
{t(language, 'was_this_helpful_thank_you')}
</span>
@@ -15,22 +15,28 @@ export function AIChatFollowupSuggestions(props: {
}
return (
<div className="mt-auto flex w-full flex-wrap justify-end gap-2">
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
key={index}
onClick={() => {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
size="medium"
variant="blank"
style={{
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
}}
/>
))}
<div className="flex grow flex-col">
<div
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
>
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
data-testid="ai-chat-followup-suggestion"
key={index}
onClick={() => {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 px-3 py-1.5 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
size="medium"
variant="blank"
style={{
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
}}
/>
))}
</div>
</div>
);
}
@@ -35,66 +35,68 @@ export function AnnouncementBanner(props: {
data-nosnippet=""
>
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
onClick={dismissAnnouncement}
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
/>
) : null}
<div className={tcls(CONTAINER_STYLE)}>
<div className="relative">
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
onClick={dismissAnnouncement}
className={`absolute top-0 right-0 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 ${style.close}`}
/>
) : null}
</div>
</div>
</div>
</div>
@@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
aria-describedby={describedById}
className={tcls(
'fixed',
'z-10',
'z-50',
'bg-tint-base',
'rounded-sm',
'straight-corners:rounded-none',
@@ -52,9 +52,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'depth-flat:shadow-none',
'p-4',
'pr-8',
'bottom-4',
'right-4',
'left-16',
'bottom-[max(env(safe-area-inset-bottom),1rem)]',
'right-[max(env(safe-area-inset-right),1rem)]',
'left-[max(env(safe-area-inset-left),4rem)]',
'max-w-md',
'text-balance',
'sm:left-auto',
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['space-y-4']}
style={['contents']}
/>
}
>
@@ -1,84 +1,30 @@
'use client';
import * as Popover from '@radix-ui/react-popover';
import type React from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Tooltip } from '@/components/primitives';
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
const { children, body } = props;
const language = useLanguage();
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
data-testid="annotation-button"
aria-label={tString(language, 'annotation_button_label')}
className={tcls(
'decoration-dotted',
'decoration-1',
'underline',
'underline-offset-2'
)}
>
{children}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className={tcls(
'text-sm',
'max-w-[280px]',
'bg-tint',
'ring-1',
'ring-tint',
'rounded-sm',
'shadow-1xs',
'shadow-tint-12/1',
'dark:shadow-tint-1/2',
'p-3',
'[&_p]:leading-snug',
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20'
)}
sideOffset={4}
>
{body}
<Popover.Arrow asChild>
<svg
viewBox="0 0 8 5"
className={tcls(
'relative',
'z-2',
'fill-tint-3', // Same as bg-tint
'stroke-tint-7', // Same as ring-tint
'[paint-order:stroke_fill]'
)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clipAnnotation)">
<path
d="M0 0L4 4L8 0"
strokeWidth="2"
strokeLinecap="round"
stroke="inherit"
fill="inherit"
/>
</g>
<defs>
<clipPath id="clipAnnotation">
<rect width="8" height="5" fill="white" />
</clipPath>
</defs>
</svg>
</Popover.Arrow>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<Tooltip
label={body}
contentProps={{
role: 'definition',
}}
className="bg-tint-base px-4 py-3 text-sm text-tint-strong shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1"
arrow={true}
arrowProps={{ className: 'fill-tint-1' }}
>
<dfn
data-testid="annotation-button"
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
tabIndex={0}
>
{children}
</dfn>
</Tooltip>
);
}
@@ -5,6 +5,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
@@ -18,7 +19,7 @@ export function Hint({
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
const hasHeading = isHeadingBlock(firstNode);
const language = contextProps.context.contentContext
? getSpaceLanguage(contextProps.context.contentContext)
@@ -31,10 +32,9 @@ export function Hint({
className={tcls(
'hint',
'transition-colors',
'rounded-md',
hasHeading ? 'rounded-l-sm' : null,
'straight-corners:rounded-none',
'rounded-corners:rounded-md',
'circular-corners:rounded-xl',
hasHeading ? 'circular-corners:rounded-l-none rounded-corners:rounded-l-none' : '',
'overflow-hidden',
hasHeading ? ['border-l-2', hintStyle.containerWithHeader] : hintStyle.container,
@@ -156,8 +156,10 @@
/** Text input */
.contentkit-textinput {
@apply w-full rounded border border-tint text-tint-strong placeholder:text-tint flex resize-none flex-1 px-2 py-1.5 text-sm bg-transparent whitespace-pre-line;
@apply focus:outline-primary focus:border-primary;
@apply w-full circular-corners:rounded-3xl ring-primary-hover rounded-corners:rounded-lg border border-tint text-tint-strong transition-all placeholder:text-tint/8 flex resize-none flex-1 px-2 py-1.5 text-sm bg-tint-base whitespace-pre-line;
@apply shadow-tint/6 depth-subtle:focus-within:-translate-y-px depth-subtle:shadow-sm depth-subtle:focus-within:shadow-lg dark:shadow-tint-1;
@apply focus:border-primary-hover focus:shadow-primary-subtle focus:ring-2 hover:border-tint-hover focus:hover:border-primary-hover;
@apply disabled:cursor-not-allowed disabled:border-tint-subtle disabled:bg-tint-subtle;
}
/** Form */
@@ -27,7 +27,10 @@ export const contentKitServerContext: ContentKitServerContext = {
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
},
// For some reason, Next thinks that this function is used in a client component
// it's likely an issue with the compiler not being able to track the usage of this function properly
markdown: async ({ className, markdown }) => {
'use server';
const parsed = await parseMarkdown(markdown);
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
},
@@ -182,7 +182,7 @@
/* Schema Presentation */
.openapi-schema-presentation {
@apply flex flex-col gap-1 font-normal;
@apply flex flex-col gap-1 font-normal scroll-mt-[calc(var(--toc-top-offset)+0.5rem)];
}
.openapi-schema-properties:last-child {
@@ -249,7 +249,7 @@
}
.openapi-schema-circular {
@apply text-xs text-tint;
@apply text-sm text-tint;
}
.openapi-schema-circular a {
@@ -257,7 +257,7 @@
}
.openapi-schema-circular-glyph {
@apply text-base;
@apply text-base mr-1;
}
/* Schema Enum */
@@ -36,13 +36,13 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
<div className="relative select-none">
<div
className={tcls(
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-original theme-muted:bg-primary-subtle tabular-nums contrast-more:bg-primary-solid',
'font-medium text-contrast-primary-original theme-muted:text-primary contrast-more:text-contrast-primary-solid'
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-solid theme-muted:bg-tint-base tabular-nums contrast-more:bg-primary-11',
'font-medium text-contrast-primary-solid theme-muted:text-tint contrast-more:text-contrast-primary-11'
)}
>
{index + 1}
</div>
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-primary-subtle" />
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-tint-6" />
</div>
<Blocks
{...contextProps}
@@ -35,8 +35,6 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
short: formatDateShort(parsedDate),
}[dateFormat];
const isSticky = parentUpdates.data.sticky;
return (
<div
className={tcls(
@@ -46,9 +44,8 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
>
<div
className={tcls(
'h-fit w-40 min-w-40 shrink-0',
// Date is sticky on larger screens & if enabled on the parent Updates block
isSticky && 'md:sticky md:top-[calc(var(--toc-top-offset)+8px)]!'
// Date is only sticky on larger screens when we use flex-row layout
'h-fit w-40 min-w-40 shrink-0 md:sticky md:top-[calc(var(--toc-top-offset)+8px)]!'
)}
>
<time
@@ -3,17 +3,17 @@ import type { DocumentMarkColor } from '@gitbook/api';
export const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
default: [],
blue: ['text-blue-500'],
red: ['text-red-500'],
green: ['text-green-500'],
yellow: ['text-yellow-600'],
purple: ['text-purple-500'],
orange: ['text-orange-500'],
$primary: ['text-primary'],
$info: ['text-info'],
$success: ['text-success'],
$warning: ['text-warning'],
$danger: ['text-danger'],
blue: ['text-blue-500 contrast-more:text-blue-800'],
red: ['text-red-500 contrast-more:text-red-800'],
green: ['text-green-500 contrast-more:text-green-800'],
yellow: ['text-yellow-600 contrast-more:text-yellow-800'],
purple: ['text-purple-500 contrast-more:text-purple-800'],
orange: ['text-orange-500 contrast-more:text-orange-800'],
$primary: ['text-primary-subtle contrast-more:text-primary'],
$info: ['text-info-subtle contrast-more:text-info'],
$success: ['text-success-subtle contrast-more:text-success'],
$warning: ['text-warning-subtle contrast-more:text-warning'],
$danger: ['text-danger-subtle contrast-more:text-danger'],
};
export const backgroundColorToStyle: {
@@ -1,12 +1,14 @@
'use client';
import { useAIChatController, useAIChatState } from '@/components/AI';
import { useAI, useAIChatController, useAIChatState } from '@/components/AI';
import {
AIChatBody,
AIChatControlButton,
AIChatDynamicIcon,
AIChatSubtitle,
getAIChatName,
} from '@/components/AIChat';
import { useLanguage } from '@/intl/client';
import * as api from '@gitbook/api';
import React from 'react';
import { useTrackEvent } from '../Insights';
@@ -16,20 +18,35 @@ import {
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSidebar,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
import {
EmbeddableIframeButtons,
EmbeddableIframeTabs,
useEmbeddableConfiguration,
} from './EmbeddableIframeAPI';
type EmbeddableAIChatProps = {
baseURL: string;
siteTitle: string;
};
/**
* Embeddable AI chat window in an iframe.
*/
export function EmbeddableAIChat(props: {
trademark: boolean;
}) {
const { trademark } = props;
export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const { baseURL, siteTitle } = props;
const chat = useAIChatState();
const { config } = useAI();
const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
const language = useLanguage();
React.useEffect(() => {
chatController.open();
}, [chatController]);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
@@ -45,27 +62,46 @@ export function EmbeddableAIChat(props: {
);
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>GitBook Assistant</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
trademark={trademark}
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
<EmbeddableFrameSidebar>
<EmbeddableIframeTabs
ref={tabsRef}
active="assistant"
baseURL={baseURL}
siteTitle={siteTitle}
/>
</EmbeddableFrameBody>
<EmbeddableIframeButtons />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain>
<EmbeddableFrameHeader>
{!tabsRef.current ? (
<AIChatDynamicIcon
className="animate-blur-in-slow"
trademark={config.trademark}
/>
) : null}
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
greeting={configuration.greeting}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
);
}
@@ -1,13 +1,13 @@
import type { GitBookSiteContext } from '@/lib/context';
import { EmbeddableAIChat } from './EmbeddableAIChat';
type EmbeddableAssistantPageProps = {
baseURL: string;
siteTitle: string;
};
/**
* Reusable page component for the embed assistant page.
*/
export async function EmbeddableAssistantPage(props: {
context: GitBookSiteContext;
}) {
const { context } = props;
return <EmbeddableAIChat trademark={context.customization.trademark.enabled} />;
export async function EmbeddableAssistantPage(props: EmbeddableAssistantPageProps) {
return <EmbeddableAIChat baseURL={props.baseURL} siteTitle={props.siteTitle} />;
}
@@ -1,18 +1,24 @@
import { type PagePathParams, getSitePageData } from '@/components/SitePage';
import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import type { Metadata } from 'next';
import { Button } from '../primitives';
import { HeaderMobileMenu } from '../Header/HeaderMobileMenu';
import { PageBody } from '../PageBody';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { TableOfContents } from '../TableOfContents';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSidebar,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
export const dynamic = 'force-static';
@@ -24,7 +30,9 @@ type EmbeddableDocsPageProps = {
/**
* Page component for the embed docs page.
*/
export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
export async function EmbeddableDocsPage(
props: EmbeddableDocsPageProps & { staticRoute: boolean }
) {
const { context, pageParams } = props;
const { page, document, ancestors, withPageFeedback } = await getSitePageData({
context,
@@ -32,33 +40,60 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
});
return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
<EmbeddableFrameHeaderMain>
<Button
href={context.linker.toPathInSite('~gitbook/embed/assistant')}
size="default"
variant="blank"
icon="arrow-left"
label="Back"
/>
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<div className="flex-1 overflow-auto p-6">
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
/>
<EmbeddableFrame className="site-background">
<EmbeddableFrameSidebar>
<EmbeddableIframeTabs
active="docs"
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
<EmbeddableIframeButtons />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain>
<div className="relative flex not-hydrated:animate-blur-in-slow flex-col">
<EmbeddableFrameHeader>
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden" />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<EmbeddableDocsPageControlButtons
href={context.linker
.toPathForPage({
pages: context.revision.pages,
page,
})
.replace(/~gitbook\/embed\/page\/?/, '')}
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
{context.sections ? (
<SiteSectionTabs
className="-mt-2 border-tint-subtle border-b"
sections={encodeClientSiteSections(context, context.sections)}
/>
) : null}
</div>
</EmbeddableFrameBody>
<EmbeddableFrameBody>
<ScrollContainer
orientation="vertical"
className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4"
leading={{ fade: !context.sections, button: true }}
>
<TableOfContents context={context} />
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
staticRoute={props.staticRoute}
/>
</ScrollContainer>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
);
}
@@ -0,0 +1,21 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
export function EmbeddableDocsPageControlButtons(props: { href: string }) {
const { href } = props;
const language = useLanguage();
return (
<Button
icon="arrow-up-right-from-square"
label={tString(language, 'open_in_new_tab')}
href={href}
target="_blank"
iconOnly
variant="blank"
size="default"
/>
);
}
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
<div
{...divProps}
className={tcls(
'flex h-full grow flex-col overflow-hidden bg-tint-base text-sm text-tint',
'flex h-full grow overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
divProps.className
)}
ref={ref}
@@ -28,13 +28,19 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
}
);
export function EmbeddableFrameMain(props: { children: React.ReactNode }) {
const { children } = props;
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
}
export function EmbeddableFrameHeader(props: {
children: React.ReactNode;
}) {
const { children } = props;
return (
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
<div className="relative z-10 flex not-hydrated:animate-blur-in-slow select-none items-center gap-2 px-4 py-2.5 text-tint-strong">
{children}
</div>
);
@@ -45,7 +51,7 @@ export function EmbeddableFrameHeaderMain(props: {
}) {
const { children } = props;
return <div className="flex flex-1 flex-col">{children}</div>;
return <div className="flex h-8 flex-1 flex-col justify-center">{children}</div>;
}
export function EmbeddableFrameBody(props: {
@@ -73,7 +79,7 @@ export function EmbeddableFrameSubtitle(props: {
return (
<div
className={tcls(
'text-tint text-xs leading-none transition-all duration-500',
'origin-left text-tint text-xs leading-none transition-all duration-500',
className
)}
>
@@ -82,10 +88,21 @@ export function EmbeddableFrameSubtitle(props: {
);
}
export function EmbeddableFrameButtons(props: {
children: React.ReactNode;
}) {
export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
const { children } = props;
return <div className="ml-auto flex gap-2">{children}</div>;
return (
<div className="flex w-13 shrink-0 origin-top not-hydrated:animate-blur-in-slow flex-col gap-2 overflow-hidden border-tint-solid/3 border-r bg-tint-solid/1 p-2 transition-all transition-discrete duration-300 empty:hidden empty:w-0 empty:px-0">
{children}
</div>
);
}
export function EmbeddableFrameButtons(props: {
className?: string;
children: React.ReactNode;
}) {
const { children, className } = props;
return <div className={tcls('flex gap-2', className)}>{children}</div>;
}
@@ -4,15 +4,17 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import { createChannel } from 'bidc';
import React from 'react';
import { useAIChatController } from '@/components/AI';
import { useAI, useAIChatController } from '@/components/AI';
import { CustomizationAIMode } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
buttons: [],
welcomeMessage: '',
tabs: [],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
}));
@@ -28,6 +30,12 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter();
const chatController = useAIChatController();
React.useEffect(() => {
return chatController.on('open', () => {
router.push(`${baseURL}/assistant`);
});
}, [router, baseURL, chatController]);
React.useEffect(() => {
if (window.parent === window) {
return;
@@ -93,23 +101,114 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
* Display the buttons defined by the parent window.
*/
export function EmbeddableIframeButtons() {
const buttons = useEmbeddableConfiguration((state) => state.buttons);
const { actions: configuredActions, buttons: configuredButtons = [] } =
useEmbeddableConfiguration((state) => state);
const actions = configuredActions.length > 0 ? configuredActions : configuredButtons;
return (
<>
{buttons.map((button) => (
{actions.length > 0 && (
<hr className="my-2 border-0 border-tint-subtle border-b first:hidden" />
)}
{actions.map((action, index) => (
<Button
key={button.label}
key={action.label}
size="default"
variant="blank"
icon={button.icon}
label={button.label}
icon={action?.icon ?? 'square-question'}
label={action?.label}
iconOnly
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
disabled={!action.onClick}
onClick={() => {
button.onClick();
action.onClick?.();
}}
tooltipProps={{
contentProps: {
side: 'right',
},
}}
style={{ animationDelay: `${index * 100}ms` }}
/>
))}
</>
);
}
export function EmbeddableIframeTabs(props: {
ref?: React.RefObject<HTMLDivElement | null>;
active?: string;
baseURL: string;
siteTitle: string;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
const { assistants, config } = useAI();
const router = useRouter();
const tabs = [
config.aiMode === CustomizationAIMode.Assistant &&
assistants[0] &&
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
? {
key: 'assistant',
label: assistants[0].label,
icon: assistants[0].icon,
onClick: () => {
router.push(`${baseURL}/assistant`);
},
}
: null,
configuredTabs.includes('docs') || configuredTabs.length === 0
? {
key: 'docs',
label: siteTitle,
icon: 'book-open',
onClick: () => {
router.push(`${baseURL}/page/`);
},
}
: null,
].filter((tab) => tab !== null);
// Override the active tab if it doesn't match the configured tabs
React.useEffect(() => {
const hasAssistant = tabs.find((tab) => tab.key === 'assistant');
const hasDocs = tabs.find((tab) => tab.key === 'docs');
if (!hasAssistant && !hasDocs) {
// No valid tabs, do not redirect
return;
}
if (active === 'assistant' && !hasAssistant) {
router.replace(`${baseURL}/page`);
} else if (active === 'docs' && !hasDocs) {
router.replace(`${baseURL}/assistant`);
}
}, [tabs, baseURL, router, active]);
return tabs.length > 1 || actions.length > 0 ? (
<div className="flex flex-col gap-2" ref={ref}>
{tabs.map((tab) => (
<Button
key={tab.key}
data-testid={`embed-tab-${tab.key}`}
label={tab.label}
size="default"
variant="blank"
icon={tab.icon}
active={tab.key === active}
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={tab.onClick}
tooltipProps={{
contentProps: {
side: 'right',
},
}}
/>
))}
</div>
) : null;
}
@@ -7,8 +7,10 @@ import {
} from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { TrademarkLink } from '../TableOfContents/Trademark';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
type EmbeddableRootLayoutProps = {
@@ -29,12 +31,17 @@ export async function EmbeddableRootLayout({
return (
<CustomizationRootLayout context={context}>
<SiteLayoutClientContexts
forcedTheme={context.customization.themes.default}
forcedTheme={
context.customization.themes.toggeable
? undefined
: context.customization.themes.default
}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
>
<AIContextProvider
aiMode={CustomizationAIMode.Assistant}
aiMode={context.customization.ai.mode}
suggestions={context.customization.ai.suggestions}
trademark={context.customization.trademark.enabled}
>
<SpaceLayoutServerContext
@@ -46,9 +53,19 @@ export async function EmbeddableRootLayout({
asEmbeddable: true,
}}
>
<div className="fixed inset-0 flex flex-col">{children}</div>
<NavigationLoader />
<div className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
<TrademarkLink
className="border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
context={context}
placement={SiteInsightsTrademarkPlacement.Embed}
/>
) : null}
</div>
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
/>
</SpaceLayoutServerContext>
</AIContextProvider>
@@ -29,7 +29,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
className={tcls(
'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens
mobileOnly ? '@7xl:hidden' : null
mobileOnly ? 'xl:hidden' : null
)}
>
<div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
@@ -25,12 +25,12 @@ export function Header(props: {
};
}) {
const { context, withTopHeader, variants } = props;
const { siteSpace, siteSpaces, sections, customization } = context;
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
const withSections = Boolean(
sections &&
(sections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
sections.list.some((s) => s.object === 'site-section-group'))
visibleSections &&
(visibleSections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
visibleSections.list.some((s) => s.object === 'site-section-group'))
);
return (
@@ -42,6 +42,7 @@ export function Header(props: {
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
@@ -139,23 +140,22 @@ export function Header(props: {
style={customization.styling.search}
withVariants={variants.generic.length > 1}
withSiteVariants={
sections?.list.some(
visibleSections?.list.some(
(s) =>
s.object === 'site-section' && s.siteSpaces.length > 1
) ?? false
}
withSections={sections ? sections.list.length > 1 : false}
withSections={
visibleSections ? visibleSections.list.length > 1 : false
}
section={
sections
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
encodeClientSiteSections(context, sections).current
visibleSections
? // Client-encode to avoid a serialization issue that was causing the language selector to disappear
encodeClientSiteSections(context, visibleSections).current
: undefined
}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
siteSpaceIds={siteSpaces
.filter((s) => s.space.language === siteSpace.space.language)
.map((s) => s.id)}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
</div>
@@ -199,9 +199,9 @@ export function Header(props: {
</div>
</div>
{sections && withSections ? (
{visibleSections && withSections ? (
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
@@ -1,40 +1,20 @@
'use client';
import { Icon } from '@gitbook/icons';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button, type ButtonProps } from '../primitives';
const globalClassName = 'navigation-open';
const SCROLL_DISTANCE = 320;
/**
* Button to show/hide the table of content on mobile.
*/
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
export function HeaderMobileMenu(props: ButtonProps) {
const language = useLanguage();
const pathname = usePathname();
const hasScrollRef = useRef(false);
const toggleNavigation = () => {
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
document.body.classList.remove(globalClassName);
} else {
document.body.classList.add(globalClassName);
window.scrollTo(0, 0);
}
};
const windowRef = useRef(typeof window === 'undefined' ? null : window);
useScrollListener(() => {
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
}, windowRef);
// Close the navigation when navigating to a page
useEffect(() => {
@@ -42,16 +22,17 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
}, [pathname]);
return (
<button
<Button
icon="bars"
iconOnly
variant="blank"
size="default"
label={tString(language, 'table_of_contents_button_label')}
onClick={() => {
document.body.classList.toggle(globalClassName);
}}
// Since the button is hidden behind the TOC after toggling, we don't need to keep track of its active state.
{...props}
aria-label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation}
className={tcls(
'flex flex-row items-center rounded-sm straight-corners:rounded-xs px-2 py-1',
props.className
)}
>
<Icon icon="bars" className="size-4 text-inherit" />
</button>
/>
);
}
@@ -289,6 +289,25 @@ export function ActionViewAsPDF(props: { url: string; type: PageActionType }) {
);
}
/**
* Action to view the page as an RSS feed.
*/
export function ActionViewAsRSS(props: { url: string; type: PageActionType }) {
const { url, type } = props;
const language = useLanguage();
return (
<PageActionWrapper
type={type}
icon="rss"
label={tString(language, 'rss_feed')}
description={tString(language, 'open_rss_feed')}
href={url}
target="_blank"
/>
);
}
/**
* Action to copy a string to the clipboard.
*/
@@ -16,6 +16,7 @@ import {
ActionOpenMCP,
ActionViewAsMarkdown,
ActionViewAsPDF,
ActionViewAsRSS,
} from './PageActions';
export type PageActionsDropdownURLs = {
@@ -23,6 +24,7 @@ export type PageActionsDropdownURLs = {
markdown: string;
mcp?: string;
pdf?: string;
rss?: string;
editOnGit?: {
provider: GitSyncState['installationProvider'];
url: string;
@@ -43,7 +45,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
const defaultAction = getPageDefaultAction(props);
const defaultAction = usePageDefaultAction(props);
const dropdownActions = getPageDropdownActions(props);
return defaultAction || dropdownActions.length > 0 ? (
@@ -127,7 +129,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
</React.Fragment>
) : null,
urls.editOnGit || urls.pdf ? (
urls.editOnGit || urls.pdf || urls.rss ? (
<React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" />
{urls.editOnGit ? (
@@ -137,6 +139,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
url={urls.editOnGit.url}
/>
) : null}
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
</React.Fragment>
) : null,
@@ -146,12 +149,16 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function getPageDefaultAction(props: PageActionsDropdownProps) {
function usePageDefaultAction(props: PageActionsDropdownProps) {
const { urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
if (urls.rss) {
return <ActionViewAsRSS url={urls.rss} type="button" />;
}
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
@@ -1,41 +0,0 @@
'use client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
export const AsideSectionHighlight = React.memo(
({
className,
}: {
className?: ClassValue;
}) => {
return (
<div
className={tcls([
'border-primary-9',
'tint:border-primary-11',
'sidebar-list-line:border-l-2',
'inset-0',
'pointer-events-none',
'absolute',
'z-0',
'sidebar-list-line:-left-px',
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'sidebar-list-line:rounded-l-none!',
'sidebar-list-pill:bg-primary',
'[html.theme-muted.sidebar-list-pill_&]:bg-primary-hover',
'[html.theme-gradient.sidebar-list-pill_&]:bg-primary-active',
'contrast-more:border',
'contrast-more:bg-primary',
className,
])}
/>
);
}
);
@@ -43,6 +43,11 @@ export function PageAside(props: {
'opacity-0',
'xl:flex',
'xl:page-no-toc:fixed',
'xl:page-no-toc:mr-[max(0px,calc((100vw-96rem)/2-20rem))]',
'xl:page-no-toc:right-8',
'xl:page-no-toc:bottom-0',
'z-20',
'overflow-hidden',
@@ -102,7 +107,7 @@ export function PageAside(props: {
className={tcls(
'flex flex-col',
'min-w-56 shrink-0',
'overflow-hidden',
'page-has-toc:overflow-hidden',
'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
@@ -169,7 +174,11 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
return sections.length > 1 ? (
<div className="overflow-y-auto">
<ScrollSectionsList sections={sections} />
</div>
) : null;
}
function PageAsideActions(props: {
@@ -204,7 +213,7 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
// 'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'pt-4'
)}
@@ -7,7 +7,6 @@ import { tcls } from '@/lib/tailwind';
import { useBodyLoaded } from '@/components/primitives';
import { HEADER_HEIGHT_DESKTOP } from '../layout';
import { AsideSectionHighlight } from './AsideSectionHighlight';
/**
* The threshold at which we consider a section as intersecting the viewport.
@@ -44,7 +43,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
return (
<ul
className="relative flex flex-col overflow-y-auto border-tint-subtle sidebar-list-line:border-l pb-5 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
ref={scrollContainerRef}
>
{sections.map((section) => (
@@ -62,22 +61,6 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
)}
ref={activeId === section.id ? activeItemRef : null}
>
{activeId === section.id && (
<AsideSectionHighlight
className={tcls(
'sidebar-list-default:hidden',
section?.depth > 1
? [
'sidebar-list-default:rounded-l-none',
'sidebar-list-line:rounded-l-none',
]
: [
'sidebar-list-default:ml-3',
'contrast-more:sidebar-list-default:ml-0',
]
)}
/>
)}
<a
href={`#${section.id}`}
className={tcls(
@@ -104,6 +87,10 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
'contrast-more:hover:ring-inset',
'contrast-more:hover:ring-current',
'sidebar-list-line:border-l-2',
'border-transparent',
'sidebar-list-line:-left-px',
section.depth > 1 && [
'subitem',
'sidebar-list-line:pl-6',
@@ -120,17 +107,20 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
'hover:text-primary',
'contrast-more:text-primary',
'contrast-more:hover:text-primary-strong',
'sidebar-list-line:ml-px',
'hover:bg-primary-hover',
'theme-muted:hover:bg-primary-active',
'[html.sidebar-filled.theme-bold.tint_&]:hover:bg-primary-active',
'theme-gradient:hover:bg-primary-active',
'tint:font-semibold',
'contrast-more:font-semibold',
'sidebar-list-line:border-primary-9',
'sidebar-list-default:border-tint',
'sidebar-list-pill:bg-primary',
'[html.theme-muted.sidebar-list-pill_&]:bg-primary-hover',
'[html.theme-gradient.sidebar-list-pill_&]:bg-primary-active',
]
)}
>
@@ -0,0 +1,20 @@
import React from 'react';
/**
* Conditionally wrap children in a Suspense component based on SSR status.
* We need to do that because otherwise ISR pages will trigger the suspense boundary on every request,
* causing flickering and bad UX.
*/
export default function OptionalSuspense(props: {
staticRoute: boolean;
fallback: React.ReactNode;
children: React.ReactNode;
}) {
const { staticRoute, fallback, children } = props;
if (staticRoute) {
return <>{children}</>;
}
return <React.Suspense fallback={fallback}>{children}</React.Suspense>;
}
@@ -1,10 +1,9 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } from '@gitbook/api';
import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document';
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -12,6 +11,7 @@ import { TrackPageViewEvent } from '../Insights';
import { PageFeedbackForm } from '../PageFeedback';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
import { DateRelative, SuspenseLoadedHint } from '../primitives';
import OptionalSuspense from './OptionalSuspense';
import { PageBodyBlankslate } from './PageBodyBlankslate';
import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
@@ -27,12 +27,26 @@ export function PageBody(props: {
document: JSONDocument | null;
withPageFeedback: boolean;
insightsDisplayContext: SiteInsightsDisplayContext;
staticRoute: boolean;
}) {
const { page, context, ancestors, document, withPageFeedback, insightsDisplayContext } = props;
const {
page,
context,
ancestors,
document,
withPageFeedback,
insightsDisplayContext,
staticRoute,
} = props;
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
// Update blocks can only be at the top level of the document, so we optimize the check.
const contentHasUpdates = document
? hasTopLevelBlock(document, (block) => block.type === 'updates')
: false;
// Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document.
const withLinkPreviews = document
? !hasMoreThan(
@@ -60,7 +74,7 @@ export function PageBody(props: {
// Allow words to break if they are too long.
'break-anywhere',
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
pageWidthWide ? 'page-width-wide max-w-[92rem]' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents && hasVisibleTOCItems
? 'page-has-toc'
@@ -72,9 +86,15 @@ export function PageBody(props: {
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
<PageHeader context={context} page={page} ancestors={ancestors} />
<PageHeader
context={context}
page={page}
ancestors={ancestors}
withRSSFeed={contentHasUpdates}
/>
{document && !isNodeEmpty(document) ? (
<React.Suspense
<OptionalSuspense
staticRoute={staticRoute}
fallback={
<DocumentViewSkeleton
document={document}
@@ -96,7 +116,7 @@ export function PageBody(props: {
withLinkPreviews,
}}
/>
</React.Suspense>
</OptionalSuspense>
) : (
<PageBodyBlankslate page={page} context={context} />
)}
@@ -46,7 +46,7 @@ export async function PageBodyBlankslate(props: {
<Card
key={child.id}
leadingIcon={icon}
title={child.title}
title={child.linkTitle || child.title}
href={resolved.href}
insights={{
type: 'link_click',
@@ -62,7 +62,14 @@ export async function PageBodyBlankslate(props: {
pages: context.revision.pages,
page: child,
});
return <Card key={child.id} title={child.title} leadingIcon={icon} href={href} />;
return (
<Card
key={child.id}
title={child.linkTitle || child.title}
leadingIcon={icon}
href={href}
/>
);
})
);
@@ -43,7 +43,7 @@ export async function PageFooterNavigation(props: {
<NavigationCard
icon="chevron-left"
label={t(language, 'previous_page')}
title={previous.title}
title={previous.linkTitle || previous.title}
href={previousHref}
insights={{
type: 'link_click',
@@ -62,7 +62,7 @@ export async function PageFooterNavigation(props: {
<NavigationCard
icon="chevron-right"
label={t(language, 'next_page')}
title={next.title}
title={next.linkTitle || next.title}
href={nextHref}
insights={{
type: 'link_click',
@@ -1,6 +1,7 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import urlJoin from 'url-join';
@@ -16,8 +17,9 @@ export async function PageHeader(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
withRSSFeed: boolean;
}) {
const { context, page, ancestors } = props;
const { context, page, ancestors, withRSSFeed } = props;
const { revision, linker } = context;
if (!page.layout.title && !page.layout.description) {
@@ -43,7 +45,7 @@ export async function PageHeader(props: {
// Show page actions if *any* of the actions are enabled
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs(context, page)}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
@@ -128,15 +130,21 @@ export async function PageHeader(props: {
/**
* Return the URLs for the page actions.
*/
function getPageActionsURLs(
context: GitBookSiteContext,
page: RevisionPageDocument
): PageActionsDropdownURLs {
function getPageActionsURLs({
context,
page,
withRSSFeed,
}: {
context: GitBookSiteContext;
page: RevisionPageDocument;
withRSSFeed: boolean;
}): PageActionsDropdownURLs {
const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page });
return {
html: context.linker.toAbsoluteURL(
context.linker.toPathForPage({ pages: context.revision.pages, page })
),
html: context.linker.toAbsoluteURL(pagePath),
// For the markdown URL, we use the page.path to ensure it works for the default page.
markdown: `${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`,
rss: withRSSFeed ? getPageRSSURL(context, page) : undefined,
editOnGit:
context.customization.git.showEditLink && context.space.gitSync?.url && page.git
? {
@@ -6,10 +6,10 @@ import React, { type ButtonHTMLAttributes } from 'react';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
import { Button, ButtonGroup } from '../primitives';
import { Button, ButtonGroup, Input } from '../primitives';
const MIN_COMMENT_LENGTH = 3;
const MAX_COMMENT_LENGTH = 512;
/**
@@ -24,7 +24,6 @@ export function PageFeedbackForm(props: {
const trackEvent = useTrackEvent();
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const [rating, setRating] = React.useState<PageFeedbackRating>();
const [comment, setComment] = React.useState('');
const [submitted, setSubmitted] = React.useState(false);
const onSubmitRating = (rating: PageFeedbackRating) => {
@@ -86,43 +85,21 @@ export function PageFeedbackForm(props: {
</ButtonGroup>
</div>
{rating ? (
<div className="flex flex-col gap-2">
{!submitted ? (
<>
<textarea
ref={inputRef}
name="comment"
className="mx-0.5 max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong"
placeholder={tString(languages, 'was_this_helpful_comment')}
aria-label={tString(languages, 'was_this_helpful_comment')}
onChange={(e) => setComment(e.target.value)}
value={comment}
rows={3}
maxLength={MAX_COMMENT_LENGTH}
/>
<div className="flex items-center justify-between gap-4">
<Button
size="small"
onClick={() => onSubmitComment(rating, comment)}
label={tString(languages, 'submit')}
/>
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
<span
className={
comment.length === MAX_COMMENT_LENGTH
? 'text-red-500'
: ''
}
>
{comment.length} / {MAX_COMMENT_LENGTH}
</span>
) : null}
</div>
</>
) : (
<p>{t(languages, 'was_this_helpful_thank_you')}</p>
)}
</div>
<Input
ref={inputRef}
label={tString(languages, 'was_this_helpful_comment')}
multiline
submitButton
rows={3}
name="page-feedback-comment"
onSubmit={(comment) => onSubmitComment(rating, comment as string)}
maxLength={MAX_COMMENT_LENGTH}
minLength={MIN_COMMENT_LENGTH}
disabled={submitted}
submitMessage={tString(languages, 'was_this_helpful_thank_you')}
className="animate-blur-in"
resize
/>
) : null}
</div>
);
@@ -57,11 +57,15 @@ function preloadFont(fontData: FontData) {
* It takes care of setting the theme and the language.
*/
export async function CustomizationRootLayout(props: {
/** The class name to apply to the html element. */
htmlClassName?: string;
/** The class name to apply to the body element. */
bodyClassName?: string;
forcedTheme?: CustomizationThemeMode | null;
context: GitBookAnyContext;
children: React.ReactNode;
}) {
const { context, forcedTheme, children } = props;
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
const customization =
'customization' in context ? context.customization : defaultCustomization();
@@ -105,7 +109,8 @@ export async function CustomizationRootLayout(props: {
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
? 'dark'
: ''
: '',
htmlClassName
)}
>
<head>
@@ -177,7 +182,7 @@ export async function CustomizationRootLayout(props: {
}
`}</style>
</head>
<body className="site-background">
<body className={tcls(bodyClassName, 'sheet-open:overflow-hidden')}>
<IconsProvider
assetsURL={GITBOOK_ICONS_URL}
assetsURLToken={GITBOOK_ICONS_TOKEN}
@@ -103,9 +103,7 @@ export function SearchAskAnswer(props: { query: string }) {
</React.Suspense>
) : null}
{askState?.type === 'error' ? (
<div className={tcls('grow p-4 text-center')}>
{t(language, 'search_ask_error')}
</div>
<div className="grow p-4 text-center">{t(language, 'search_ask_error')}</div>
) : null}
{askState?.type === 'loading' ? loading : null}
</div>
@@ -193,7 +191,15 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
const getSearchLinkProps = useSearchLink();
return (
<div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4')}>
<div
className={tcls(
'flex',
'flex-col',
'flex-wrap',
'mt-4',
'animate-[heightIn_.5s_ease_both]'
)}
>
{followupQuestions.map((question) => (
<Link
key={question}
@@ -1,7 +1,7 @@
'use client';
import { t, useLanguage } from '@/intl/client';
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
import { CustomizationSearchStyle, type SiteSection, type SiteSpace } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -21,14 +21,11 @@ import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
interface SearchContainerProps {
/** The current site space id. */
siteSpaceId: string;
/** The current site space. */
siteSpace: SiteSpace;
/** The title of the current space. */
spaceTitle: string;
/** The ids of all spaces in the current section. */
siteSpaceIds: string[];
/** All site spaces in the current section. */
siteSpaces: ReadonlyArray<SiteSpace>;
/** Whether there are sections on the site. */
withSections: boolean;
@@ -50,21 +47,18 @@ interface SearchContainerProps {
/**
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const {
siteSpaceId,
spaceTitle,
section,
withVariants,
withSiteVariants,
withSections,
style,
className,
viewport,
siteSpaceIds,
} = props;
const { assistants } = useAI();
export function SearchContainer({
siteSpace,
section,
withVariants,
withSiteVariants,
withSections,
style,
className,
viewport,
siteSpaces,
}: SearchContainerProps) {
const { assistants, config } = useAI();
const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState();
@@ -181,13 +175,33 @@ export function SearchContainer(props: SearchContainerProps) {
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
const searchResultsId = `search-results-${React.useId()}`;
// If searching all variants of the current section (the "extended" scope),
// filter by language if the language is set for both the current and the target site space.
const siteSpaceIds = React.useMemo(
() =>
siteSpaces.reduce((acc: string[], ss) => {
if (
!siteSpace.space.language ||
!ss.space.language ||
ss.space.language === siteSpace.space.language
) {
acc.push(ss.id);
}
return acc;
}, []),
[siteSpaces, siteSpace.space.language]
);
const { results, fetching, error } = useSearchResults({
disabled: !(state?.query || withAI),
query: normalizedQuery,
siteSpaceId,
siteSpaceId: siteSpace.id,
siteSpaceIds,
scope: state?.scope ?? 'default',
withAI: withAI,
withAI,
suggestions: config.suggestions,
});
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
@@ -233,7 +247,7 @@ export function SearchContainer(props: SearchContainerProps) {
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
<SearchScopeControl
section={section}
spaceTitle={spaceTitle}
spaceTitle={siteSpace.title}
withVariants={withVariants}
withSiteVariants={withSiteVariants}
withSections={withSections}
@@ -314,8 +328,7 @@ export function SearchContainer(props: SearchContainerProps) {
* Screen reader announcement for search results.
* Without it there is no feedback for screen reader users when a search returns no results.
*/
function LiveResultsAnnouncer(props: { count: number; showing: boolean }) {
const { count, showing } = props;
function LiveResultsAnnouncer({ count, showing }: { count: number; showing: boolean }) {
const language = useLanguage();
return (
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
@@ -1,13 +1,9 @@
'use client';
import React from 'react';
import { useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Button, variantClasses } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { useClassnames } from '../primitives/StyleProvider';
import { Input } from '../primitives';
interface SearchInputProps {
onChange: (value: string) => void;
@@ -20,14 +16,11 @@ interface SearchInputProps {
children?: React.ReactNode;
}
// Size classes for medium size button
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', '@2xl:circular-corners:px-4'];
/**
* Input to trigger search.
*/
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
function SearchInput(props, ref) {
function SearchInput(props, containerRef) {
const {
onChange,
onKeyDown,
@@ -42,7 +35,6 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
const inputRef = useRef<HTMLInputElement>(null);
const language = useLanguage();
const buttonStyles = useClassnames(['ButtonStyles']);
useEffect(() => {
if (isOpen) {
@@ -58,74 +50,45 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
}, [isOpen, value]);
return (
<div className={tcls('relative flex size-9 grow', className)}>
{/* 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 @2xl:cursor-text cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover',
'theme-bold:border-header-link/3 has-[input:focus-visible]:theme-bold:border-header-link/5 has-[input:focus-visible]:theme-bold:bg-header-link/3 has-[input:focus-visible]:theme-bold: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.
'@max-2xl:absolute relative @max-2xl:right-0 z-30 max-w-none shrink grow justify-start',
isOpen ? '@max-2xl:w-56' : '@max-2xl:w-[38px]'
)}
>
{value && isOpen ? (
<Button
variant="blank"
label={tString(language, 'clear')}
size="medium"
iconOnly
icon="circle-xmark"
className="-ml-1.5 -mr-1 animate-scale-in px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
/>
) : (
<div className="relative flex @max-2xl:size-9.5 grow">
<Input
data-testid="search-input"
name="search-input"
ref={inputRef}
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
sizing="medium"
label={tString(language, withAI ? 'search_or_ask' : 'search')}
className="@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow theme-bold:border-header-link/4 theme-bold:bg-header-link/1 @max-2xl:px-2.5 theme-bold:text-header-link theme-bold:shadow-none! theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] theme-bold:focus-within:border-header-link/6 theme-bold:focus-within:ring-header-link/5 theme-bold:hover:border-header-link/5 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11"
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}`}
onFocus={onFocus}
onKeyDown={onKeyDown}
leading={
<Icon
icon="magnifying-glass"
className="size-4 shrink-0 animate-scale-in"
icon="search"
className="size-4 shrink-0 text-tint theme-bold:text-header-link/8"
/>
)}
{children}
<input
{...rest}
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, withAI ? 'search_or_ask' : 'search')}`}
maxLength={512}
size={10}
data-testid="search-input"
className={tcls(
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-hidden transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
isOpen ? '' : '@max-2xl:opacity-0'
)}
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
// Forward
ref={inputRef}
/>
<KeyboardShortcut
keys={isOpen ? ['esc'] : ['mod', 'k']}
className="last:-mr-1 theme-bold:border-header-link/5 theme-bold:bg-header-background theme-bold:text-header-link"
/>
</div>
}
onChange={(event) => {
onChange(event.target.value);
}}
value={value}
maxLength={512}
autoComplete="off"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
clearButton={{
className:
'theme-bold:text-header-link theme-bold:hover:bg-header-link/3 text-base',
}}
keyboardShortcut={{
className:
'theme-bold:border-header-link/4 theme-bold:bg-header-background theme-bold:text-header-link',
keys: isOpen ? ['esc'] : ['mod', 'k'],
}}
{...rest}
type="text"
/>
</div>
);
}
@@ -66,53 +66,6 @@ export interface AskAnswerResult {
sources: AskAnswerSource[];
}
/**
* Server action to search content in the entire site.
*/
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchAllSiteContent', async () => {
const context = await getServerActionBaseContext();
return searchSiteContent(context, {
query,
scope: { mode: 'all' },
});
});
}
/**
* Server action to search content in a space.
*/
export async function searchCurrentSiteSpaceContent(
query: string,
siteSpaceId: string
): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext();
return await searchSiteContent(context, {
query,
scope: { mode: 'current', siteSpaceId },
});
});
}
/**
* Server action to search content in a specific space.
*/
export async function searchSpecificSiteSpaceContent(
query: string,
siteSpaceIds: string[]
): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext();
return await searchSiteContent(context, {
query,
scope: { mode: 'specific', siteSpaceIds },
});
});
}
/**
* Server action to ask a question in a space.
*/
@@ -245,69 +198,72 @@ export async function streamRecommendedQuestions(args: { siteSpaceId?: string })
/**
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
*/
async function searchSiteContent(
context: GitBookBaseContext,
args: {
query: string;
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
}
): Promise<OrderedComputedResult[]> {
const { dataFetcher } = context;
const siteURLData = await getSiteURLDataFromMiddleware();
export async function searchSiteContent({
query,
...scope
}: {
query: string;
} & (
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] }
)): Promise<OrderedComputedResult[]> {
return traceErrorOnly(`Search.searchSiteContent.${scope.mode}`, async () => {
if (query.length <= 1) {
return [];
}
const { scope, query } = args;
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
if (query.length <= 1) {
return [];
}
const [searchResults, { structure }] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
),
throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
),
]);
const [searchResults, { structure }] = await Promise.all([
throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
query,
scope,
})
),
throwIfDataError(
dataFetcher.getPublishedContentSite({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
siteShareKey: siteURLData.shareKey,
})
),
]);
return (
await Promise.all(
searchResults.map((spaceItem) => {
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === spaceItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return (
await Promise.all(
searchResults.map((spaceItem) => {
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === spaceItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return Promise.all(
spaceItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
})
)
).flat(2);
return Promise.all(
spaceItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup:
(siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
})
)
).flat(2);
});
}
async function transformAnswer(
@@ -372,7 +328,7 @@ async function transformAnswer(
wrapBlocksInSuspense: false,
withLinkPreviews: false, // We don't want to render link previews in the AI answer.
}}
style={['space-y-5']}
style="space-y-5 *:origin-top-left *:animate-blur-in-slow"
/>
) : null,
followupQuestions: answer.followupQuestions,
@@ -5,13 +5,12 @@ import { assert } from 'ts-essentials';
import {
type OrderedComputedResult,
searchAllSiteContent,
searchCurrentSiteSpaceContent,
searchSpecificSiteSpaceContent,
searchSiteContent,
streamRecommendedQuestions,
} from './server-actions';
import { type Assistant, useAI } from '@/components/AI';
import assertNever from 'assert-never';
import { useTrackEvent } from '../Insights';
import { isQuestion } from './isQuestion';
import type { SearchScope } from './useSearch';
@@ -36,8 +35,9 @@ export function useSearchResults(props: {
siteSpaceIds: string[];
scope: SearchScope;
withAI: boolean;
suggestions?: string[];
}) {
const { disabled, query, siteSpaceId, siteSpaceIds, scope } = props;
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions } = props;
const trackEvent = useTrackEvent();
@@ -79,6 +79,22 @@ export function useSearchResults(props: {
const questions = new Set<string>();
const recommendedQuestions: ResultType[] = [];
if (suggestions && suggestions.length > 0) {
suggestions.forEach((question) => {
questions.add(question);
});
setResultsState({
results: suggestions.map((question, index) => ({
type: 'recommended-question',
id: `recommended-question-${index}`,
question,
})),
fetching: false,
error: false,
});
return;
}
const timeout = setTimeout(async () => {
if (cancelled) {
return;
@@ -123,23 +139,26 @@ export function useSearchResults(props: {
const timeout = setTimeout(async () => {
try {
const results = await (() => {
if (scope === 'all') {
// Search all content on the site
return searchAllSiteContent(query);
switch (scope) {
case 'all':
// Search all content on the site
return searchSiteContent({ query, mode: 'all' });
case 'default':
// Search the current section's variant + matched/default variant for other sections
return searchSiteContent({ query, mode: 'current', siteSpaceId });
case 'extended':
// Search all variants of the current section
return searchSiteContent({ query, mode: 'specific', siteSpaceIds });
case 'current':
// Search only the current section's current variant
return searchSiteContent({
query,
mode: 'specific',
siteSpaceIds: [siteSpaceId],
});
default:
assertNever(scope);
}
if (scope === 'default') {
// Search the current section's variant + matched/default variant for other sections
return searchCurrentSiteSpaceContent(query, siteSpaceId);
}
if (scope === 'extended') {
// Search all variants of the current section
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
}
if (scope === 'current') {
// Search only the current section's current variant
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
}
throw new Error(`Unhandled search scope: ${scope}`);
})();
if (cancelled) {
@@ -154,7 +173,11 @@ export function useSearchResults(props: {
return;
}
setResultsState({ results, fetching: false, error: false });
const aiEnrichedResults = withAI
? withAskTriggers(results, query, assistants)
: results;
setResultsState({ results: aiEnrichedResults, fetching: false, error: false });
trackEvent({
type: 'search_type_query',
@@ -173,16 +196,9 @@ export function useSearchResults(props: {
cancelled = true;
clearTimeout(timeout);
};
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds, disabled]);
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds, disabled, suggestions]);
const aiEnrichedResults: ResultType[] = React.useMemo(() => {
if (!withAI) {
return resultsState.results;
}
return withAskTriggers(resultsState.results, query, assistants);
}, [resultsState.results, query, withAI]);
return { ...resultsState, results: aiEnrichedResults };
return resultsState;
}
/**
@@ -56,6 +56,7 @@ export async function SiteLayout(props: {
>
<AIContextProvider
aiMode={customization.ai?.mode}
suggestions={context.customization.ai?.suggestions}
trademark={customization.trademark.enabled}
>
<SpaceLayout
@@ -100,6 +101,7 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
width: 'device-width',
initialScale: 1,
maximumScale: 1,
viewportFit: 'cover',
};
}
@@ -3,6 +3,7 @@ import { getDataOrNull, getPageDocument } from '@/lib/data';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -17,6 +18,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getResizedImageURL } from '@/lib/images';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { PageContextProvider } from '../PageContext';
import { PageClientLayout } from './PageClientLayout';
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
@@ -51,7 +53,7 @@ export type PageMetaLinks = {
/**
* Fetch and render a page.
*/
export async function SitePage(props: SitePageProps) {
export async function SitePage(props: SitePageProps & { staticRoute: boolean }) {
const {
context,
page,
@@ -97,6 +99,7 @@ export async function SitePage(props: SitePageProps) {
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Site}
staticRoute={props.staticRoute}
/>
</div>
<PageClientLayout pageMetaLinks={pageMetaLinks} />
@@ -121,7 +124,7 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro
* A string concatenation of the site structure (sections and variants) titles.
*/
function getSiteStructureTitle(context: GitBookSiteContext): string | null {
const { sections, siteSpace, siteSpaces } = context;
const { visibleSections: sections, siteSpace, visibleSiteSpaces: siteSpaces } = context;
const title = [];
if (
@@ -155,8 +158,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
}
const { page, ancestors } = pageTarget;
const { site, customization, revision, linker, imageResizer } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const { customization, revision, linker, imageResizer } = context;
const canonical = (
pageMetaLinks?.canonical
@@ -193,20 +195,16 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
);
return {
title: [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | '),
title: getPageFullTitle(context, page),
description: page.description ?? '',
alternates: {
canonical,
languages: alternates?.languages,
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
// We always reference the RSS feed even if the page doesn't have updates blocks,
// It might result in 404, but we can't know here if the page has updates blocks.
'application/rss+xml': [{ url: getPageRSSURL(context, page), title: 'RSS Feed' }],
// Currently it will output with an empty "type" like <link rel="alternate" href="..." type />
// Team at Vercel is aware of this and will ensure it will be omitted when the value is empty in future versions of Next.js
// https://gitbook.slack.com/archives/C04K6MV5W1K/p1763034072958419?thread_ts=1762937203.511629&cid=C04K6MV5W1K
@@ -262,7 +260,7 @@ export async function getSitePageData(props: SitePageProps) {
);
}
const { customization, sections } = context;
const { customization, visibleSections } = context;
const { page, ancestors } = pageTarget;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -273,7 +271,7 @@ export async function getSitePageData(props: SitePageProps) {
);
const withPageFeedback = customization.feedback.enabled;
const withSections = Boolean(sections && sections.list.length > 0);
const withSections = Boolean(visibleSections && visibleSections.list.length > 0);
const document = await getPageDocument(context, page);
@@ -299,7 +297,7 @@ async function getPageDataWithFallback(args: {
}) {
const { context: baseContext, pagePathParams } = args;
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
const pageMetaLinks = await (pageTarget?.page && shouldResolveMetaLinks(context.site.id)
const pageMetaLinks = await (pageTarget?.page
? resolvePageMetaLinks(context, pageTarget.page.id)
: null);
@@ -362,24 +360,18 @@ async function resolvePageMetaLinks(
}
/**
* Determine whether to resolve meta links for a site based on a percentage rollout.
* Get the <title> for a page.
*/
function shouldResolveMetaLinks(siteId: string): boolean {
const META_LINKS_PERCENTAGE_ROLLOUT = 25;
const ALLOWED_SITES: Record<string, boolean> = {
site_CZrtk: true,
};
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
if (ALLOWED_SITES[siteId] || process.env.NODE_ENV === 'development') {
return true;
}
// compute a simple hash of the siteId
let hash = 0;
for (let i = 0; i < siteId.length; i++) {
hash = (hash << 5) - hash + siteId.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash % 100) < META_LINKS_PERCENTAGE_ROLLOUT;
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}
@@ -40,7 +40,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="pb-4"
activeId={currentSection.id}
active={`#${currentSection.id}`}
>
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
@@ -18,7 +18,7 @@ import type {
} from './encodeClientSiteSections';
const SCREEN_OFFSET = 16; // 1rem
const MAX_ITEMS_PER_COLUMN = 5; // number of items per column
const MAX_ITEMS_PER_COLUMN = 8; // number of items per column
/**
* A set of navigational links representing site sections for multi-section sites
*/
@@ -33,6 +33,7 @@ export function SiteSectionTabs(props: {
children,
} = props;
const containerRef = React.useRef<HTMLDivElement>(null);
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
const [offset, setOffset] = React.useState<number | null>(null);
const [value, setValue] = React.useState<string | undefined>();
@@ -41,12 +42,15 @@ export function SiteSectionTabs(props: {
React.useEffect(() => {
const trigger = currentTriggerRef.current;
if (!value || !trigger) {
const container = containerRef.current;
if (!value || !trigger || !container) {
return;
}
const triggerWidth = trigger.getBoundingClientRect().width;
const triggerLeft = trigger.getBoundingClientRect().left;
const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
const triggerLeft =
trigger.getBoundingClientRect().left -
(window.innerWidth - container.getBoundingClientRect().width) / 2;
setOffset(triggerLeft + triggerWidth / 2);
}, [value]);
@@ -58,6 +62,7 @@ export function SiteSectionTabs(props: {
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]',
className
)}
ref={containerRef}
value={value}
onValueChange={setValue}
skipDelayDuration={500}
@@ -71,8 +76,12 @@ export function SiteSectionTabs(props: {
? 'md:-mr-8 -mr-4 sm:-mr-6'
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)}
activeId={currentSection.id}
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
active={`#${currentSection.id}`}
trailing={{
fade: true,
button: true,
className: children ? 'after:border-tint' : '',
}}
>
<NavigationMenu.List
className={tcls(
@@ -141,14 +150,14 @@ export function SiteSectionTabs(props: {
{children}
<div
className="fixed top-full left-0 z-20 flex w-full"
className="absolute top-full left-0 z-20 flex w-full"
style={{
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
}}
>
<NavigationMenu.Viewport
className={tcls(
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg transition-transform duration-250 ease-in-out',
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg ease-in-out',
'-mt-0.5 w-full md:w-max',
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
@@ -156,7 +165,7 @@ export function SiteSectionTabs(props: {
style={{
translate:
!isMobile && offset
? `clamp(0px, calc(${offset}px - ${SCREEN_OFFSET}px - 50%), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
? `clamp(0px, calc(${offset}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
display: offset === null ? 'none' : undefined,
}}
@@ -218,7 +227,7 @@ function SectionGroupTileList(props: {
{hasSections && (
<ul
className={tcls(
'flex grid-flow-row flex-col gap-x-2 gap-y-1 p-2 md:grid',
'flex min-w-48 grid-flow-row flex-col gap-x-2 gap-y-1 self-start p-3 md:grid',
hasGroups ? 'bg-tint-base' : ''
)}
style={{
@@ -239,7 +248,7 @@ function SectionGroupTileList(props: {
{hasGroups && (
<ul
className={tcls(
'flex grid-flow-col flex-col items-start gap-x-2 gap-y-4 p-2 md:grid md:gap-y-1',
'flex min-w-48 flex-col items-start justify-start gap-x-8 gap-y-2 p-3 md:flex-row md:gap-y-8',
hasSections
? 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l'
: ''
@@ -271,7 +280,7 @@ function SectionGroupTile(props: {
const { url, icon, title, description } = child;
const isActive = child.id === currentSection.id;
return (
<li className="group/section-tile flex w-full shrink grow md:w-68">
<li className="group/section-tile flex shrink-0 grow md:max-w-68">
<Link
href={url}
className={tcls(
@@ -312,7 +321,7 @@ function SectionGroupTile(props: {
const { title, icon, children } = child;
return (
<li className="flex w-full shrink grow flex-col gap-1">
<li className="flex shrink-0 flex-col gap-1">
<div className="mt-3 mb-2 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
{icon && (
<SectionIcon className="mt-0.5" isActive={false} icon={icon as IconName} />
@@ -322,7 +331,7 @@ function SectionGroupTile(props: {
<ul
className="flex grid-flow-row flex-col gap-x-2 gap-y-1 md:grid"
style={{
gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, auto))`,
}}
>
{children.map((nestedChild) => (
@@ -14,6 +14,7 @@ function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
// Only the properties used by categorizeVariants are required for these tests
siteSpace: current,
siteSpaces: all,
visibleSiteSpaces: all,
} as unknown as Parameters<typeof categorizeVariants>[0];
}

Some files were not shown because too many files have changed in this diff Show More