Compare commits

..

92 Commits

Author SHA1 Message Date
Zeno Kapitein 79ce105caa Fix 404 for embed root page
Going to `/~gitbook/embed/page/` should load the space's root page, but returns a 404 instead. This fixes that.
2025-11-18 19:38:20 +01:00
Greg Bergé 854e612e5d Fix handling of broken links in buttons (#3812) 2025-11-18 11:12:27 +01:00
Samy Pessé 27a461be9d Fix NPM publishing once and for all (#3811) 2025-11-16 02:16:20 +01:00
Samy Pessé 3d83dadb12 New attempt at fixing NPM publishing with trusted publisher (#3810) 2025-11-16 01:34:57 +01:00
Samy Pessé 8dc5c18b9f Pass proper GITHUB_TOKEN to the publish script 2025-11-16 00:35:16 +01:00
Samy Pessé e4756c23a7 Use dedicated step to publish to NPM 2025-11-16 00:28:37 +01:00
Samy Pessé 79ad7bcb59 Fix publishing to NPM (#3809) 2025-11-16 00:25:26 +01:00
Samy Pessé 5b0917a13a Remove --dry-run in publish script 2025-11-15 23:36:35 +01:00
Samy Pessé 6d6f4ef6dd Fix the script to publish npm packages and make it more robust 2025-11-15 23:31:14 +01:00
Samy Pessé fa69a64a2b Merge branch 'main' of https://github.com/GitbookIO/gitbook 2025-11-15 19:47:52 +01:00
Samy Pessé d1a85d1f91 Try fixing the publishing workflow 2025-11-15 19:47:36 +01:00
Samy Pessé 05bf9d7a59 Version Packages (#3733)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-15 19:34:29 +01:00
Samy Pessé 2e0b3370c8 Fix permissions for publish workflow 2025-11-15 19:32:41 +01:00
Samy Pessé 10995e09ca Use trusted publishing for publishing NPM packages (#3808) 2025-11-15 19:26:26 +01:00
Samy Pessé e7cc3ff61f Revert "Use NPM dependency for eval-estree-expression"
This reverts commit 59d259074b.
2025-11-15 18:13:10 +01:00
Samy Pessé 59d259074b Use NPM dependency for eval-estree-expression 2025-11-15 17:58:54 +01:00
Zeno Kapitein 96c4ae4f2d Fix Heading in hint blocks are huge in published content (#3806) 2025-11-14 15:26:46 +01:00
Taran Vohra 64f3596df2 Output generic alternates for page metadata (#3804) 2025-11-14 16:32:19 +05:30
Taran Vohra 27d6c3af39 Use an available alternate when switching variants in a page (#3796) 2025-11-14 15:19:40 +05:30
Nolann B. 1de4eb7973 Fix @modelcontextprotocol/sdk version (#3805) 2025-11-13 15:38:33 +01:00
Nolann B. df1966df25 Bump Scalar (#3803) 2025-11-13 15:29:25 +01:00
conico974 1001aaf94e Refactor DynamicTabs to use NavigationStatusContext (#3800) 2025-11-13 14:37:09 +01:00
Zeno Kapitein 155e448c8f Support primary link in logo (#3802) 2025-11-13 12:59:17 +01:00
Viktor Renkema ca2a94ee1a Support sticky date + new third date format (#3801) 2025-11-13 12:09:53 +01:00
Nolann B. a512c90bda Re-arrange OpenAPI Scopes for OAuth2 (#3799) 2025-11-12 19:40:21 +01:00
conico974 9cbefa89c7 Filter invalid events sent to the API (#3792) 2025-11-12 11:54:13 +01:00
Taran Vohra c8bba7bfe1 Resolve page meta links for metadata (#3793) 2025-11-12 16:12:27 +05:30
Viktor Renkema 4e04db8a1b Support rendering upcoming Update blocks (#3794) 2025-11-12 10:16:02 +01:00
Nolann B. b45feafeff Disable OpenAPI "Try it" when no servers are defined (#3791) 2025-11-10 17:25:22 +01:00
Zeno Kapitein 3876f7081e Edgeless Assistant window on depth: flat sites (#3788) 2025-11-10 14:37:28 +01:00
Nolann B. b4a021a192 Add heredoc support for cURL JSON body (#3790) 2025-11-10 11:20:53 +01:00
Zeno Kapitein 1d92dc1350 Update site layout and blocks with container queries (#3780) 2025-11-10 10:55:03 +01:00
Nolann B. e4ba35fdae Rework OpenAPI Authorizations + scopes (#3786) 2025-11-06 21:20:09 +01:00
Viktor Renkema 35dcaca971 Support custom cover heights (#3779) 2025-11-05 14:21:47 +01:00
Zeno Kapitein 2aa4f2e0b8 Update DropdownMenu styling (#3787) 2025-11-05 08:40:53 +01:00
Zeno Kapitein 6d56df7c10 Treat pages without visible TOC items as no-toc (#3783) 2025-11-05 08:40:15 +01:00
Zeno Kapitein 4534ea83c3 Implement unique title tags for sections & variants (#3768) 2025-11-04 11:36:28 +01:00
Zeno Kapitein 9022d87ec7 Add page breadcrumbs to search results (#3784) 2025-11-04 00:31:32 +01:00
Brett Jephson a49781e200 Expand code block when printing (#3782) 2025-11-03 15:26:18 +00:00
Steven H 065a5e6e55 Disable Workers Observability (#3781) 2025-11-03 11:17:33 +00:00
Zeno Kapitein e27986b17a Reword "Everywhere" search scope to "All docs" (#3776) 2025-11-03 09:57:21 +01:00
Zeno Kapitein 8606f222c9 Add padding to TOC page group items (#3777) 2025-11-03 09:56:39 +01:00
Nolann B. 2c3066e3ae Improve OAuth2 scopes handling in OpenAPI (#3775) 2025-10-30 15:46:29 +01:00
Zeno Kapitein 55c0b03a41 Support translations and generic variants together (#3772) 2025-10-30 15:03:50 +01:00
conico974 3e548e4186 Initialize hash on mount and improve navigation handling (#3774) 2025-10-30 14:33:26 +01:00
Viktor Renkema 3aea302648 Revert "Support cover image heights and positioning changes" (#3771) 2025-10-30 10:24:22 +01:00
Zeno Kapitein 1d1f0a7c6a Fix search container shadow (#3770) 2025-10-29 11:14:31 +01:00
conico974 590729cce5 Redirect to space base path on fallback true (#3745) 2025-10-29 10:55:27 +01:00
conico974 b554d7d13f Add isRoot prop to PagesList for root header offset (#3759) 2025-10-29 10:50:53 +01:00
conico974 9568b8a7c8 Add keep_names option to wrangler configurations (#3769) 2025-10-29 10:00:19 +01:00
Zeno Kapitein 683bb8f634 Add confirmation to "Copy MCP URL" page action (#3763) 2025-10-28 12:42:02 +01:00
Zeno Kapitein 7d6a31627f Revert contentref anchor title (#3767) 2025-10-28 12:27:59 +01:00
Brett Jephson c31e5648f5 RND-8495: page group emoji and icon resolution (#3766) 2025-10-28 10:23:08 +00:00
Zeno Kapitein 0c01832684 SearchInput fixes (#3762) 2025-10-28 09:50:31 +01:00
Zeno Kapitein 6b1c9fef86 Fix opacity of ScrollContainer buttons on bold themes (#3764) 2025-10-28 09:37:23 +01:00
Claire Chabas cc976995cc Fix image frame width (#3765) 2025-10-27 20:01:12 +01:00
Nolann B. ebbb476589 Improve ancestor label in references (#3761) 2025-10-27 16:49:48 +01:00
Greg Bergé 89655cb428 Fix reset scroll issue (#3760) 2025-10-27 16:41:39 +01:00
Zeno Kapitein 3676d64bdc Update search scope UI (#3750) 2025-10-27 15:57:47 +01:00
Viktor Renkema f682df1e01 Support cover image heights and positioning changes (#3758) 2025-10-27 15:55:50 +01:00
Greg Bergé 6c9893945d Fix scrolling hash into tabs / expandable (#3757) 2025-10-25 15:30:52 +02:00
Greg Bergé a2fb627ae1 Fix scroll jump and scroll reset (#3756) 2025-10-25 13:04:34 +02:00
Nolann B. f9f80117f0 Add alt text support to card covers (#3715) 2025-10-24 16:27:23 +02:00
conico974 63bf4dbb16 Fix cross-space content reference resolution and enhance anchor text rendering (#3754) 2025-10-24 15:26:59 +02:00
Brett Jephson b3fa503aff Expandable code block (#3753) 2025-10-24 14:01:31 +01:00
Zeno Kapitein 0ae7cfd790 Improve AI Search branding (#3752) 2025-10-24 14:36:31 +02:00
Brett Jephson 8fdc5c89dc Fix: height issues with webframe (#3749) 2025-10-24 10:29:46 +01:00
Zeno Kapitein 758b34d46b Move the "Ask <query>" to the bottom of search results if query is not a question (#3751) 2025-10-24 10:58:51 +02:00
Zeno Kapitein 1b0613e482 Tweak language dropdown styling (#3748) 2025-10-22 22:00:52 +02:00
conico974 3164f1a99d Fix preloading images (#3747) 2025-10-22 15:09:42 +02:00
Greg Bergé 6b588af07d Clarify CONTRIBUTING.md (#3742) 2025-10-22 12:43:28 +00:00
Greg Bergé d436b04ff4 Fix jump when clicking tabs (#3746) 2025-10-21 18:30:33 +02:00
conico974 d159788d0b Add end-to-end tests for table of contents navigation (#3741) 2025-10-21 12:39:23 +02:00
Nolann B. 3355960d95 Fix ToggeableLinkItem display (#3744)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-10-21 10:32:17 +02:00
Nolann B. c32d4494f2 Fix OpenAPI path display (#3743) 2025-10-19 21:44:26 +02:00
conico974 1dcf4df51f Fix table of contents display issue (#3740) 2025-10-17 19:20:33 +00:00
conico974 3c0e49d56b Fix cache race condition (#3738) 2025-10-17 19:42:16 +02:00
Greg Bergé c35d0e8954 Run tests automatically when "Version packages" PR is opened (#3737) 2025-10-17 14:28:38 +02:00
Greg Bergé c0d73d2f76 Improve tabs (#3735) 2025-10-17 12:13:09 +02:00
Viktor Renkema 454175ab86 Change copy of toolbar button (#3736) 2025-10-17 07:17:39 +00:00
Greg Bergé 6feb8fcd6a Support emojis in outline (#3734) 2025-10-16 23:49:12 +02:00
conico974 b8d3cb7ee5 Improve search error handling and token redirection (#3732) 2025-10-16 15:39:32 +02:00
Nolann B. 8ce73229c3 Add OpenAPI servers selection (#3726) 2025-10-16 14:27:42 +02:00
Greg Bergé 8eae6e970b Upgrade to bun 1.3 (#3731) 2025-10-16 10:22:52 +02:00
Greg Bergé a48b886dfd Add button to download file (#3730) 2025-10-15 17:46:47 +02:00
Greg Bergé 17de0838c5 Allow to split pages between list items (#3729) 2025-10-15 14:02:41 +02:00
Greg Bergé baf3496980 Improve inline tooltip performances (#3728) 2025-10-15 13:30:38 +02:00
Samy Pessé ae33c09aa7 Version Packages (#3724)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-15 13:30:24 +02:00
Greg Bergé 7ff69c3b72 Fix bun token (#3727) 2025-10-15 10:43:44 +02:00
Greg Bergé 6142d6bf80 Build all packages using tsdown (#3722) 2025-10-14 14:39:19 +02:00
Brett Jephson 75543b140f Update Node version to work with globalThis.process.getBuiltinModule (#3725) 2025-10-14 11:13:26 +01:00
Nolann B. 511f1cfbe5 Add scroll margin to Expandable (#3723) 2025-10-13 15:54:32 +02:00
241 changed files with 5145 additions and 2786 deletions
+3 -4
View File
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally #### 3. Install dependencies and run the project locally
##### Prerequisites: ##### Prerequisites:
- Node.js (Version: >=20.6)
- Use `nvm` for easy Node management - [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
- We use a text-based lockfile which isn't supported below 1.2.15
##### Setup steps: ##### Setup steps:
+3 -2
View File
@@ -4,8 +4,9 @@ on:
push: push:
branches: branches:
- main - main
- changeset-release/main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -41,7 +42,7 @@ jobs:
name: Build (Open Source) name: Build (Open Source)
timeout-minutes: 6 timeout-minutes: 6
env: env:
NPM_TOKEN_READONLY: '' BUN_NPM_TOKEN: ''
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1 -1
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (preview) name: Deploy v2 to Vercel (preview)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (production) name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (staging) name: Deploy v2 to Vercel (staging)
+16 -8
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -14,30 +14,38 @@ jobs:
publish: publish:
name: Publish name: Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
pull-requests: write
contents: write
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0 fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: Setup Bun - name: Setup Bun
uses: ./.github/composite/setup-bun uses: ./.github/composite/setup-bun
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request or Publish to npm - name: Create Release Pull Request
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@v1
with: with:
publish: npm run release publish: bun run publish-all-packages
version: npm run changeset-version version: bun run changeset-version
env: env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created # Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+4
View File
@@ -25,3 +25,7 @@ yarn-error.log*
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
.npmrc
# Bun pack artifacts
packages/*/*.tgz
+1 -1
View File
@@ -1 +1 @@
v20.6 v22.3
+2 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >=20.6) - Node.js (Version: >= 22.3)
- Use nvm for easy Node management - Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.15
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
``` ```
.env.local .env.local
NPM_TOKEN_READONLY=xxx BUN_NPM_TOKEN=xxx
``` ```
and then reinstall dependencies. and then reinstall dependencies.
+484 -849
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes] [install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" } "gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+24 -8
View File
@@ -1,17 +1,22 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.7", "@changesets/cli": "^2.29.7",
"turbo": "^2.5.8", "turbo": "^2.6.1",
"vercel": "^39.4.2" "vercel": "^39.4.2"
}, },
"packageManager": "bun@1.2.15", "packageManager": "bun@1.3.2",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"react": "^19.0.0", "@types/react": "catalog:",
"react-dom": "^19.0.0", "@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2" "esbuild": "0.24.2"
}, },
"private": true, "private": true,
@@ -27,17 +32,28 @@
"e2e-customers": "turbo run e2e-customers", "e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update", "changeset-version": "changeset version && bun run format && bun update",
"release": "turbo run release && bun run publish-all-packages", "publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "0.143.2", "@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.150.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"assert-never": "^1.4.0",
"bidc": "^0.0.2", "bidc": "^0.0.2",
"tsdown": "^0.15.6" "bun-types": "^1.1.20",
"classnames": "^2.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+16
View File
@@ -1,5 +1,21 @@
# @gitbook/browser-types # @gitbook/browser-types
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+12 -4
View File
@@ -8,21 +8,29 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.1.1", "sideEffects": false,
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:" "@gitbook/icons": "workspace:"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/cache-tags # @gitbook/cache-tags
## 0.3.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.3.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.3.2 ## 0.3.2
### Patch Changes ### Patch Changes
+13 -7
View File
@@ -4,26 +4,32 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.2", "sideEffects": false,
"version": "0.3.4",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"assert-never": "^1.2.1" "assert-never": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/colors # @gitbook/colors
## 0.4.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.4.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.4.1 ## 0.4.1
### Patch Changes ### Patch Changes
+12 -6
View File
@@ -4,22 +4,28 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.4.1", "sideEffects": false,
"version": "0.4.3",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+16
View File
@@ -1,5 +1,21 @@
# @gitbook/embed # @gitbook/embed
## 0.1.5
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.4
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.3 ## 0.1.3
### Patch Changes ### Patch Changes
+11 -5
View File
@@ -12,19 +12,21 @@
"default": "./dist/react/index.js" "default": "./dist/react/index.js"
} }
}, },
"version": "0.1.3", "sideEffects": false,
"version": "0.1.5",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:", "@gitbook/icons": "workspace:",
"bidc": "catalog:" "bidc": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0" "react": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "^5.5.3", "typescript": "catalog:",
"react": "^19.0.0" "react": "catalog:"
}, },
"scripts": { "scripts": {
"build": "bun run build-lib && bun run build-standalone", "build": "bun run build-lib && bun run build-standalone",
@@ -32,11 +34,15 @@
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone", "build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+8 -6
View File
@@ -1,4 +1,6 @@
import React from 'react'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
GetFrameURLOptions, GetFrameURLOptions,
GitBookEmbeddableConfiguration, GitBookEmbeddableConfiguration,
@@ -17,19 +19,19 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props; const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null); const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook(); const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null); const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
React.useEffect(() => { useEffect(() => {
if (frameRef.current) { if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current)); setGitbookFrame(gitbook.createFrame(frameRef.current));
} }
}, [gitbook]); }, [gitbook]);
React.useEffect(() => { useEffect(() => {
gitbookFrame?.configure({ gitbookFrame?.configure({
buttons, buttons,
welcomeMessage, welcomeMessage,
+7 -3
View File
@@ -2,8 +2,12 @@ import { defineConfig } from 'tsdown';
export default defineConfig([ export default defineConfig([
{ {
entry: ['src/index.ts', 'src/react/index.ts'], entry: 'src/index.ts',
dts: true, outDir: 'dist',
format: ['esm'], },
{
entry: 'src/react/index.ts',
outDir: 'dist/react',
unbundle: true,
}, },
]); ]);
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/emoji-codepoints # @gitbook/emoji-codepoints
## 0.2.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.2.1 ## 0.2.1
### Patch Changes ### Patch Changes
+3 -7
View File
@@ -1,19 +1,15 @@
{ {
"name": "@gitbook/emoji-codepoints", "name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.1", "version": "0.2.2",
"private": true, "private": true,
"exports": "./dist/index.ts", "exports": "./dist/index.ts",
"dependencies": {}, "sideEffects": false,
"devDependencies": { "devDependencies": {
"emoji-assets": "^8.0.0" "emoji-assets": "^9.0.0"
}, },
"scripts": { "scripts": {
"generate": "bun ./build.ts", "generate": "bun ./build.ts",
"clean": "rm -rf ./dist" "clean": "rm -rf ./dist"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
} }
} }
+13
View File
@@ -1,5 +1,18 @@
# @gitbook/expr # @gitbook/expr
## 1.2.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- 10995e0: Use NPM dependency for eval-estree-expression
## 1.2.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 1.2.2 ## 1.2.2
### Patch Changes ### Patch Changes
+14 -9
View File
@@ -1,42 +1,47 @@
{ {
"name": "@gitbook/expr", "name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.", "description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.2", "version": "1.2.4",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
"acorn": "^8.14.0", "acorn": "^8.14.0",
"acorn-loose": "8.4.0", "acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4", "acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0", "escodegen": "^2.1.0",
"assert-never": "^1.2.1" "eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.20", "@tsconfig/strictest": "catalog:",
"tsdown": "catalog:", "@tsconfig/node20": "catalog:",
"@types/escodegen": "^0.0.10",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test", "unit": "bun test",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"dev": "bun run build -- --watch ./src" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/fonts # @gitbook/fonts
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+14 -8
View File
@@ -4,32 +4,38 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.1.1", "sideEffects": false,
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:" "@gitbook/api": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"google-font-metadata": "^6.0.3", "bun-types": "catalog:",
"typescript": "^5.5.3" "google-font-metadata": "^6.0.7",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"generate": "bun ./bin/generate.js", "generate": "bun ./bin/generate.js",
"build": "tsc --project tsconfig.build.json", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w", "dev": "bun run build -- --watch ./src",
"clean": "rm -rf ./dist && rm -rf ./src/data", "clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test" "unit": "bun test",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"], "files": ["dist", "bin", "README.md", "CHANGELOG.md"],
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
@@ -1,57 +1,57 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`getDefaultFont should return correct object for Latin text 1`] = ` exports[`getDefaultFont should return correct object for Latin text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = ` exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Greek text 1`] = ` exports[`getDefaultFont should return correct object for Greek text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle mixed script text 1`] = ` exports[`getDefaultFont should handle mixed script text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: regular 1`] = ` exports[`getDefaultFont should handle different font weights: regular 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: bold 1`] = ` exports[`getDefaultFont should handle different font weights: bold 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: inter 1`] = ` exports[`getDefaultFont should handle different fonts: inter 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: roboto 1`] = ` exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{ {
"font": "Roboto", "font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff", "url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
} }
`; `;
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
+75
View File
@@ -1,5 +1,80 @@
# gitbook # gitbook
## 0.20.0
### Minor Changes
- 1d92dc1: Update site layout and blocks with container queries and better transitions
### Patch Changes
- 8fdc5c8: Fix webframe height issue
- 9022d87: Add breadcrumbs to search results
- 0c01832: SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
- 1d1f0a7: Fix search container shadow
- 2aa4f2e: Update dropdown menu styling
- a49781e: Printable collapsed code block
- 7d6a316: Revert contentref anchor title
- e27986b: Reword "Everywhere" search scope to "All docs"
- 758b34d: Move the "Ask <query>" to the bottom of search results if query is not a question
- 0ae7cfd: Improve AI Search branding
- f9f8011: Add alt text support to card covers
- ebbb476: Improve ancestor label in references
- 1b0613e: Tweak language dropdown styling
- 3355960: Fix ToggeableLinkItem display
- c31e564: Fix icon resolution for page group
- 55c0b03: Support translations and generic variants together
- 3876f70: Edgeless Assistant window on `depth: flat` sites
- 683bb8f: Add confirmation to "Copy MCP URL" page action
- 4534ea8: Implement unique title tags for sections & variants
- 6d56df7: Treat pages without visible TOC items as `no-toc`
- 8606f22: Add padding to TOC page group items
- 3676d64: Update search scope UI
- c32d449: Fix OpenAPI path display
- 8ce7322: Add OpenAPI servers selection
- 2c3066e: Improve OAuth2 scopes handling in OpenAPI
- 6b1c9fe: Fix opacity of ScrollContainer buttons on bold themes
- Updated dependencies [b4a021a]
- Updated dependencies [a512c90]
- Updated dependencies [df1966d]
- Updated dependencies [b45feaf]
- Updated dependencies [10995e0]
- Updated dependencies [f9f8011]
- Updated dependencies [10995e0]
- Updated dependencies [8ce7322]
- Updated dependencies [2c3066e]
- @gitbook/react-openapi@1.5.3
- @gitbook/openapi-parser@3.0.5
- @gitbook/react-contentkit@0.7.8
- @gitbook/browser-types@0.1.3
- @gitbook/cache-tags@0.3.4
- @gitbook/react-math@0.6.4
- @gitbook/colors@0.4.3
- @gitbook/embed@0.1.5
- @gitbook/fonts@0.1.3
- @gitbook/icons@0.3.4
- @gitbook/expr@1.2.4
## 0.19.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- 75543b1: Update minimum Node version in .npmrc
- 511f1cf: Add scroll margin to Expandable
- Updated dependencies [6142d6b]
- @gitbook/emoji-codepoints@0.2.2
- @gitbook/react-contentkit@0.7.7
- @gitbook/openapi-parser@3.0.4
- @gitbook/browser-types@0.1.2
- @gitbook/react-openapi@1.5.2
- @gitbook/cache-tags@0.3.3
- @gitbook/react-math@0.6.3
- @gitbook/colors@0.4.2
- @gitbook/fonts@0.1.2
- @gitbook/icons@0.3.3
- @gitbook/expr@1.2.3
## 0.19.1 ## 0.19.1
### Patch Changes ### Patch Changes
+5 -10
View File
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://adiblar.gitbook.io', contentBaseURL: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{ // {
name: 'docs.gradient.network', // name: 'docs.gradient.network',
contentBaseURL: 'https://docs.gradient.network', // contentBaseURL: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }], // tests: [{ name: 'Home', url: '/' }],
}, // },
// { // {
// name: 'mygate-network.gitbook.io', // name: 'mygate-network.gitbook.io',
// contentBaseURL: 'https://mygate-network.gitbook.io', // contentBaseURL: 'https://mygate-network.gitbook.io',
@@ -172,11 +172,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.realapp.link', contentBaseURL: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{
name: 'docs.plaza.finance',
contentBaseURL: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{ {
name: 'docs.publicai.io', name: 'docs.publicai.io',
contentBaseURL: 'https://docs.publicai.io', contentBaseURL: 'https://docs.publicai.io',
+115 -22
View File
@@ -33,6 +33,7 @@ import {
headerLinks, headerLinks,
runTestCases, runTestCases,
waitForCookiesDialog, waitForCookiesDialog,
waitForCoverImages,
waitForNotFound, waitForNotFound,
} from './util'; } from './util';
@@ -159,20 +160,20 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
}, },
}, },
{ // {
name: 'Ask - AI Mode: Assistant - Button', // name: 'Ask - AI Mode: Assistant - Button',
url: getCustomizationURL({ // url: getCustomizationURL({
ai: { // ai: {
mode: CustomizationAIMode.Assistant, // mode: CustomizationAIMode.Assistant,
}, // },
}), // }),
screenshot: false, // screenshot: false,
run: async (page) => { // run: async (page) => {
await page.getByTestId('ai-chat-button').click(); // await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible(); // await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); // await expect(page.getByTestId('ai-chat-input')).toBeFocused();
}, // },
}, // },
{ {
name: 'Ask - AI Mode: Assistant - URL query (Initial)', name: 'Ask - AI Mode: Assistant - URL query (Initial)',
url: `${getCustomizationURL({ url: `${getCustomizationURL({
@@ -228,6 +229,58 @@ const testCases: TestsCase[] = [
); );
}, },
}, },
{
name: 'Expandable TOC navigation',
url: '',
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Navigation" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Navigation' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// Verify "Navigation" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
{
name: 'Expandable nested TOC navigation',
url: '',
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Spaces" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Spaces' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// At this stage the link should still not be visible
await expect(navigationLink).not.toBeVisible();
// Then we click 'Content Structure' chevron to expand further
const contentStructureChevron = page
.getByRole('link', { name: 'Content Structure' })
.locator('span');
await contentStructureChevron.click();
// Verify "Spaces" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
...searchTestCases, ...searchTestCases,
{ {
name: 'Not found', name: 'Not found',
@@ -285,6 +338,37 @@ const testCases: TestsCase[] = [
).toBeVisible(); ).toBeVisible();
}, },
}, },
{
name: 'Switch variant with alternate link in metadata',
url: 'rfcs',
run: async (page) => {
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the variant space called 'Multi-Variants' for which
// there is an alternate link in the current (RFC variant) page metadata
await variantSelectionDropdown
.getByRole('menuitem', {
name: 'Multi-Variants',
})
.click();
// It should navigate to the alternate link defined in the metadata (a completely different page)
await page.waitForURL((url) =>
url.pathname.includes('multi-variants/reference/api-reference/pets')
);
// Verify we are on the correct page by checking the h1
await expect(
page.getByRole('heading', { level: 1, name: 'Pets' })
).toBeVisible();
},
},
], ],
}, },
{ {
@@ -727,7 +811,10 @@ const testCases: TestsCase[] = [
url: 'blocks/integrations', url: 'blocks/integrations',
run: async (page) => { run: async (page) => {
await waitForCookiesDialog(page); await waitForCookiesDialog(page);
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame(); const mermaidIframe = page
.locator('iframe[title*="mermaid"]')
.first()
.contentFrame();
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
}, },
@@ -851,7 +938,10 @@ const testCases: TestsCase[] = [
{ {
name: 'With cover', name: 'With cover',
url: 'page-options/page-with-cover', url: 'page-options/page-with-cover',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover for dark mode', name: 'With cover for dark mode',
@@ -866,12 +956,18 @@ const testCases: TestsCase[] = [
{ {
name: 'With hero cover', name: 'With hero cover',
url: 'page-options/page-with-hero-cover', url: 'page-options/page-with-hero-cover',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover and no TOC', name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
screenshot: { screenshot: {
waitForTOCScrolling: false, waitForTOCScrolling: false,
}, },
@@ -1454,10 +1550,7 @@ const testCases: TestsCase[] = [
locale, locale,
}, },
}), }),
run: async (page) => { run: waitForCookiesDialog,
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
},
})), })),
}, },
{ {
+11 -1
View File
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
export async function waitForCookiesDialog(page: Page) { export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog'); const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible({
// Cookies dialog may take some times to appear
timeout: 10_000,
});
} }
export async function waitForNotFound(_page: Page, response: Response | null) { export async function waitForNotFound(_page: Page, response: Response | null) {
@@ -151,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404); expect(response?.status()).toBe(404);
} }
export async function waitForCoverImages(page: Page) {
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
timeout: 10_000,
});
}
/** /**
* Transform test cases into Playwright tests and run it. * Transform test cases into Playwright tests and run it.
*/ */
+4 -2
View File
@@ -7,7 +7,8 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), incrementalCache: () =>
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -18,7 +19,8 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), incrementalCache: () =>
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -1,15 +1,13 @@
{ {
"main": "default.js", "main": "default.js",
"name": "gitbook-open-v2-server", "name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -2,14 +2,12 @@
"main": "do.js", "main": "do.js",
"name": "gitbook-open-v2-do", "name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"env": { "env": {
"preview": { "preview": {
"vars": { "vars": {
@@ -2,6 +2,7 @@
"main": "middleware.js", "main": "middleware.js",
"name": "gitbook-open-v2", "name": "gitbook-open-v2",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
@@ -11,9 +12,6 @@
"directory": "../../.open-next/assets", "directory": "../../.open-next/assets",
"binding": "ASSETS" "binding": "ASSETS"
}, },
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -7,8 +7,6 @@ import type {
} from '@opennextjs/aws/types/overrides.js'; } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare'; import { getCloudflareContext } from '@opennextjs/cloudflare';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
@@ -23,7 +21,7 @@ export type KeyOptions = {
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast. * R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
*/ */
class GitbookIncrementalCache implements IncrementalCache { export class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache'; name = 'GitbookIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>( async get<CacheType extends CacheEntryType = 'cache'>(
@@ -137,12 +135,3 @@ class GitbookIncrementalCache implements IncrementalCache {
); );
} }
} }
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -0,0 +1,11 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -0,0 +1,12 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+19 -15
View File
@@ -1,22 +1,25 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.19.1", "version": "0.20.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*", "@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*", "@gitbook/expr": "workspace:*",
"@gitbook/embed": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*", "@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/cloudflare": "^1.9.2", "@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.11.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
@@ -28,19 +31,15 @@
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2", "@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2", "ai": "^4.2.2",
"assert-never": "^1.2.1", "assert-never": "catalog:",
"bidc": "catalog:", "bidc": "catalog:",
"bun-types": "^1.1.20", "classnames": "catalog:",
"classnames": "^2.5.1",
"direction": "^2.0.1", "direction": "^2.0.1",
"event-iterator": "^2.0.0", "event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mcp-handler": "^1.0.2", "mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2", "mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1", "mdast-util-frontmatter": "^2.0.1",
@@ -49,7 +48,7 @@
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12", "motion": "^12.23.24",
"next": "15.4.0", "next": "15.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
@@ -58,6 +57,8 @@
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"quick-lru": "^7.0.1", "quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
@@ -73,7 +74,7 @@
"unist-util-remove": "^4.0.0", "unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "catalog:",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"zod": "^3", "zod": "^3",
"zustand": "^5.0.3" "zustand": "^5.0.3"
@@ -81,6 +82,7 @@
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.9", "@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2", "@playwright/test": "^1.54.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5", "@types/jsontoxml": "^1.0.5",
@@ -89,9 +91,10 @@
"@types/node": "^20", "@types/node": "^20",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4", "@types/parse-cache-control": "^1.0.4",
"@types/react": "18.3.13", "@types/react": "catalog:",
"@types/react-dom": "18.3.1", "@types/react-dom": "catalog:",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"bun-types": "catalog:",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -99,8 +102,9 @@
"stylelint": "^16.16.0", "stylelint": "^16.16.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1", "ts-essentials": "^10.0.1",
"typescript": "^5.5.3", "typescript": "catalog:",
"vercel": "^39.3.0" "vercel": "^39.3.0",
"wrangler": "^4.43.0"
}, },
"scripts": { "scripts": {
"generate": "./scripts/generate.sh", "generate": "./scripts/generate.sh",
+5 -3
View File
@@ -19,7 +19,7 @@ export type RouteLayoutParams = {
}; };
export type RouteParams = RouteLayoutParams & { export type RouteParams = RouteLayoutParams & {
pagePath: string; pagePath?: string[];
}; };
/** /**
@@ -80,8 +80,10 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
* Get the decoded page path from the params. * Get the decoded page path from the params.
*/ */
export function getPagePathFromParams(params: RouteParams) { export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath); if (!params.pagePath || params.pagePath.length === 0) {
return decoded; return '';
}
return params.pagePath.map((part) => decodeURIComponent(part)).join('/');
} }
function getSiteURLFromParams(params: RouteLayoutParams) { function getSiteURLFromParams(params: RouteLayoutParams) {
+3 -12
View File
@@ -1,14 +1,14 @@
'use client'; 'use client';
import { CustomizationAIMode } from '@gitbook/api'; import { CustomizationAIMode } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import * as React from 'react'; import * as React from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import type { GitBookAssistant } from '@gitbook/browser-types'; import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.'; import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, getAIChatName } from '../AIChat'; import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations'; import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch'; import { useSearch } from '../Search/useSearch';
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
assistants.push({ assistants.push({
id: 'gitbook-ai-search', id: 'gitbook-ai-search',
label: tString(language, 'ai_chat_context_badge'), label: tString(language, 'ai_chat_context_badge'),
icon: ( icon: <AISearchIcon />,
<div className="relative">
<Icon icon="search" className="size-4" />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className="absolute top-[2.5px] left-[2.6px] size-2"
/>
</div>
),
open: (query?: string) => { open: (query?: string) => {
if (query) { if (query) {
setSearchState((prev) => setSearchState((prev) =>
@@ -2,6 +2,7 @@
import { t, tString, useLanguage } from '@/intl/client'; import { t, tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@@ -65,16 +66,17 @@ export function AIChat(props: { trademark: boolean }) {
} }
}, [chat.opened, trackEvent]); }, [chat.opened, trackEvent]);
if (!chat.opened) {
return null;
}
return ( return (
<div <div
data-testid="ai-chat" data-testid="ai-chat"
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96" 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'
)}
> >
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"> <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> <EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} /> <AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain> <EmbeddableFrameHeaderMain>
@@ -23,7 +23,7 @@ export function AIChatButton(props: {
iconOnly={!showLabel} iconOnly={!showLabel}
size="medium" size="medium"
variant="header" variant="header"
className="h-9 px-2.5 max-md:[&_.button-content]:hidden" className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
label={ label={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)} {t(language, 'ai_chat_ask', assistant.label)}
@@ -1,5 +1,5 @@
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon, IconStyle } from '@gitbook/icons';
import type React from 'react'; import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> { interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
trademark?: boolean; trademark?: boolean;
} }
export const AIChatIcon = ({ export function AIChatIcon({
className = 'size-4', className = 'size-4',
size, size,
trademark = true, trademark = true,
state = 'default', state = 'default',
...props ...props
}: AIChatIconProps) => { }: AIChatIconProps) {
if (!trademark) { if (!trademark) {
return ( return (
<Icon <Icon
icon="sparkle" icon="sparkle"
{...props}
className={tcls( className={tcls(
className, className,
(state === 'thinking' || state === 'working') && (state === 'thinking' || state === 'working') &&
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
</g> </g>
</svg> </svg>
); );
}; }
export function AISearchIcon({
className = 'size-4',
state = 'default',
}: Pick<AIChatIconProps, 'className' | 'state'>) {
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]'
: ''
)}
>
<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)]'
: ''
)}
/>
</div>
);
}
@@ -11,7 +11,7 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: { export function AIChatMessages(props: {
chat: AIChatState; chat: AIChatState;
chatController: AIChatController; chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>; lastUserMessageRef?: React.RefObject<HTMLDivElement | null>;
}) { }) {
const { chat, chatController, lastUserMessageRef } = props; const { chat, chatController, lastUserMessageRef } = props;
@@ -81,11 +81,19 @@ export function AIChatMessages(props: {
); );
} }
function HoldMessage() { export function HoldMessage({
breakLines = false,
className = '',
}: { breakLines?: boolean; className?: string }) {
const language = useLanguage(); const language = useLanguage();
return ( return (
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle"> <div
className={tcls(
'animate-[heightIn_500ms_4500ms_ease_both] overflow-hidden py-2 text-tint-subtle',
className
)}
>
{tString(language, 'ai_chat_hold_message_1') {tString(language, 'ai_chat_hold_message_1')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
@@ -99,6 +107,7 @@ function HoldMessage() {
{word}{' '} {word}{' '}
</span> </span>
))} ))}
{breakLines ? <br /> : null}
{tString(language, 'ai_chat_hold_message_2') {tString(language, 'ai_chat_hold_message_2')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
icon="comment" icon="comment"
/> />
{/* Open production site */} {/* Open published/live site */}
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open production site" title="Open live site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
/> />
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open production site" title="Open live site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -35,6 +35,7 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
} }
setOpen(false); setOpen(false);
}; };
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu); useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll // Close arc menu on scroll
@@ -6,7 +6,7 @@ import {
useReducedMotion, useReducedMotion,
useSpring, useSpring,
} from 'motion/react'; } from 'motion/react';
import React from 'react'; import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo'; import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext'; import { useToolbarControls } from './ToolbarControlsContext';
@@ -291,6 +291,10 @@ function ToolbarButtonWrapper(props: {
x: springX, x: springX,
}; };
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, { return React.cloneElement(child, {
motionValues, motionValues,
}); });
@@ -136,7 +136,7 @@ const calculateSpacing = (
export function useMagnificationEffect(props: { export function useMagnificationEffect(props: {
childrenCount: number; childrenCount: number;
containerRef: React.RefObject<HTMLElement>; containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig; config?: MagnificationConfig;
}) { }) {
const { childrenCount, containerRef, config } = props; const { childrenCount, containerRef, config } = props;
@@ -6,6 +6,7 @@ import {
SkeletonImage, SkeletonImage,
SkeletonParagraph, SkeletonParagraph,
SkeletonSmall, SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives'; } from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind'; import type { ClassValue } from '@/lib/tailwind';
@@ -34,6 +35,8 @@ import { Stepper } from './Stepper';
import { StepperStep } from './StepperStep'; import { StepperStep } from './StepperStep';
import { Table } from './Table'; import { Table } from './Table';
import { Tabs } from './Tabs'; import { Tabs } from './Tabs';
import { Update } from './Update';
import { Updates } from './Updates';
export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps { export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps {
block: Block; block: Block;
@@ -105,6 +108,10 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <Stepper {...props} block={block} />; return <Stepper {...props} block={block} />;
case 'stepper-step': case 'stepper-step':
return <StepperStep {...props} block={block} />; return <StepperStep {...props} block={block} />;
case 'updates':
return <Updates {...props} block={block} />;
case 'update':
return <Update {...props} block={block} />;
case 'if': case 'if':
// If block should be processed by the API. // If block should be processed by the API.
return null; return null;
@@ -165,10 +172,13 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'images': case 'images':
case 'drawing': case 'drawing':
return <SkeletonImage id={id} style={style} />; return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} style={style} />;
case 'image': case 'image':
case 'code-line': case 'code-line':
case 'tabs-item': case 'tabs-item':
case 'column': case 'column':
case 'update':
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`); throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
default: default:
return nullIfNever(block); return nullIfNever(block);
@@ -1,9 +1,18 @@
import { type DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api'; import {
type ContentRef,
type DocumentBlockContentRef,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { Card } from '@/components/primitives'; import { Card, type CardProps } from '@/components/primitives';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import type { BlockProps } from './Block'; import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) { export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props; const { block, context, style } = props;
@@ -16,51 +25,45 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null; : null;
if (!resolved) { if (!resolved) {
return null; const fallback = resolveContentRefFallback(block.data.ref);
if (!fallback) {
return null;
}
return (
<NotFoundRefHoverCard context={context}>
<BlockContentRefCard
contentRef={block.data.ref}
resolved={fallback}
style={style}
/>
</NotFoundRefHoverCard>
);
} }
const isContentInOtherSpace = return <BlockContentRefCard contentRef={block.data.ref} resolved={resolved} style={style} />;
context.contentContext?.space && }
'space' in block.data.ref &&
context.contentContext.space.id !== block.data.ref.space;
const kind = block?.data?.ref?.kind;
if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
return <SpaceRefCard {...props} resolved={resolved} />;
}
function BlockContentRefCard(
props: {
resolved: ResolvedContentRef;
contentRef: ContentRef;
} & Omit<CardProps, 'href' | 'title'>
) {
const { ref, resolved, contentRef, ...rest } = props;
return ( return (
<Card <Card
ref={ref}
leadingIcon={resolved.icon ? resolved.icon : null} leadingIcon={resolved.icon ? resolved.icon : null}
href={resolved.href} href={resolved.href}
title={resolved.text} title={resolved.text}
style={style}
insights={{ insights={{
type: 'link_click', type: 'link_click',
link: { link: {
target: block.data.ref, target: contentRef,
position: SiteInsightsLinkPosition.Content, position: SiteInsightsLinkPosition.Content,
}, },
}} }}
/> {...rest}
);
}
async function SpaceRefCard(
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>
) {
const { context, style, resolved } = props;
const spaceId = context.contentContext?.space.id;
if (!spaceId) {
return null;
}
return (
<Card
href={resolved.href}
title={resolved.text}
postTitle={resolved.subText}
style={style}
/> />
); );
} }
@@ -51,11 +51,13 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
* Ideally we'd rely on the block type to determine if it can be full width, but * Ideally we'd rely on the block type to determine if it can be full width, but
* the block's `fullWidth` property does not differentiate between `undefined` and `false`. * the block's `fullWidth` property does not differentiate between `undefined` and `false`.
* So instead we hardcode a list of blocks that can be full width. */ * So instead we hardcode a list of blocks that can be full width. */
const FULL_WIDTH_BLOCKS = [ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'table', 'table',
'tabs', 'tabs',
'integration', 'integration',
'openapi', 'openapi-operation',
'openapi-schemas',
'openapi-webhook',
'images', 'images',
'embed', 'embed',
'columns', 'columns',
@@ -64,6 +66,8 @@ const FULL_WIDTH_BLOCKS = [
'hint', 'hint',
]; ];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
/** /**
* Renders a list of blocks without a wrapper element. * Renders a list of blocks without a wrapper element.
*/ */
@@ -85,10 +89,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`} key={node.key || `${node.type}-${index}`}
block={node} block={node}
style={[ style={[
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid', 'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl' ? 'max-w-screen-xl'
: 'max-w-3xl', : 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full', FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle, blockStyle,
]} ]}
@@ -1,11 +1,14 @@
'use client'; 'use client';
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive'; import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener'; import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener'; import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts'; import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression'; import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
@@ -16,14 +19,17 @@ import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[]; inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables; inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
}; };
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
/** /**
* Render a code-block client-side by loading the highlighter asynchronously. * Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. * It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/ */
export function ClientCodeBlock(props: ClientBlockProps) { export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines, inlineExprVariables } = props; const { block, mode, style, inlines, inlineExprVariables } = props;
const blockRef = useRef<HTMLDivElement>(null); const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false); const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
@@ -113,13 +119,84 @@ export function ClientCodeBlock(props: ClientBlockProps) {
setLines(null); setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]); }, [isInViewport, block, inlines, evaluateInlineExpression]);
return ( const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
expandable && mode !== 'print' && numberOfLinesOfCode > collapsedLineCount
);
const codeBlockBodyId = useId();
const renderer = (
<CodeBlockRenderer <CodeBlockRenderer
ref={blockRef} ref={blockRef}
aria-busy={highlighting} aria-busy={highlighting}
block={block} block={block}
style={style} style={style}
lines={lines ?? plainLines} lines={lines ?? plainLines}
id={codeBlockBodyId}
/> />
); );
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
>
{renderer}
</CodeBlockExpandable>
) : (
renderer
);
}
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
collapsedLineCount: number;
controls?: string;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
)}
style={
{
'--line-count': collapsedLineCount,
'--line-height': '1.25rem',
} as React.CSSProperties
}
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<Button
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
size="xsmall"
variant="blank"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
</Button>
</div>
</div>
);
} }
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body }; return { inline, body };
}); });
if (!isEstimatedOffscreen && !hasInlineExpression) { if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// In v2, we render the code block server-side // In v2, we render the code block server-side
const lines = await highlight(block, richInlines); const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />; return <CodeBlockRenderer block={block} style={style} lines={lines} />;
@@ -73,6 +73,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
style={style} style={style}
inlines={richInlines} inlines={richInlines}
inlineExprVariables={variables} inlineExprVariables={variables}
mode={context.mode}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[]; lines: HighlightLine[];
'aria-busy'?: boolean; 'aria-busy'?: boolean;
id?: string;
}; };
/** /**
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) { ) {
const { block, style, lines, 'aria-busy': ariaBusy } = props; const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap'; const withWrap = block.data.overflow === 'wrap';
const title = block.data.title; const title = block.data.title;
const id = useId();
const codeId = props.id || id;
return ( return (
<div <div
ref={ref} ref={ref}
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null} ) : null}
</div> </div>
<CopyCodeButton <CopyCodeButton
codeId={id} codeId={codeId}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11" style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/> />
<pre <pre
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
)} )}
> >
<code <code
id={id} id={codeId}
className={tcls( className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap', 'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap' withWrap && 'whitespace-pre-wrap'
@@ -80,6 +80,7 @@ export function Column(props: {
<div <div
className={tcls( className={tcls(
'flex flex-col', 'flex flex-col',
'@container/column',
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) && (verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
'justify-start', 'justify-start',
verticalAlignment === VerticalAlignment.Middle && 'justify-center', verticalAlignment === VerticalAlignment.Middle && 'justify-center',
@@ -31,6 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing" alt="Drawing"
sizes={imageBlockSizes} sizes={imageBlockSizes}
zoom zoom
loading="lazy"
/> />
</Caption> </Caption>
); );
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }} sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]} sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer} resize={context.contentContext.imageResizer}
loading="lazy"
/> />
) : null ) : null
} }
@@ -17,29 +17,34 @@ export function Details(props: {
}) { }) {
const { children, id, className } = props; const { children, id, className } = props;
const detailsRef = React.useRef<HTMLDetailsElement>(null); const ref = React.useRef<HTMLDetailsElement>(null);
const [openFromHash, setOpenFromHash] = React.useState(false); const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash(); const hash = useHash();
/** /**
* Open the details element if the url hash refers to the id of the details element * Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element. * or the id of some element contained within the details element.
*/ */
React.useEffect(() => { React.useEffect(() => {
if (!hash || !detailsRef.current) { if (!hash || !ref.current) {
return; return;
} }
if (hash === id) { if (hash === id) {
setOpenFromHash(true); setOpenFromHash(true);
return;
} }
const activeElement = document.getElementById(hash); const activeElement = document.getElementById(hash);
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement))); const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
setOpenFromHash(isOpen);
}, [hash, id]); }, [hash, id]);
return ( return (
<details <details
ref={detailsRef} ref={ref}
id={id} id={id}
open={props.open || openFromHash} open={props.open || openFromHash}
className={tcls( className={tcls(
@@ -25,7 +25,11 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id; id = context.getId ? context.getId(id) : id;
return ( return (
<Details id={id} open={context.mode === 'print'} className={style}> <Details
id={id}
open={context.mode === 'print'}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary <summary
className={tcls( className={tcls(
'cursor-pointer', 'cursor-pointer',
@@ -47,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
className={tcls( className={tcls(
'inline-block', 'inline-block',
'size-3', 'size-3',
'mr-2', 'mr-3',
'mb-1', 'mb-1',
'transition-transform', 'transition-transform',
'shrink-0', 'shrink-0',
@@ -92,7 +96,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
document={document} document={document}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
context={context} context={context}
style={['px-10', 'pb-5', 'space-y-4']} style="space-y-4 px-10 pb-5"
/> />
</Details> </Details>
); );
@@ -1,10 +1,12 @@
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api'; import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files'; import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives'; import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
import { DownloadButton } from '../primitives/DownloadButton';
import type { BlockProps } from './Block'; import type { BlockProps } from './Block';
import { Caption } from './Caption'; import { Caption } from './Caption';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
@@ -12,67 +14,70 @@ import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) { export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props; const { block, context } = props;
const contentRef = context.contentContext if (!context.contentContext) {
? await resolveContentRef(block.data.ref, context.contentContext) return null;
: null; }
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const file = contentRef?.file; const file = contentRef?.file;
if (!file) { if (!file) {
return null; return null;
} }
const language = getSpaceLanguage(context.contentContext);
const contentType = getSimplifiedContentType(file.contentType); const contentType = getSimplifiedContentType(file.contentType);
const insights = {
type: 'link_click' as const,
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
};
return ( return (
<Caption {...props} withBorder> <Caption {...props} withBorder>
<Link <div className="flex flex-wrap items-center gap-5 px-5 py-3">
href={file.downloadURL} <div className="flex min-w-14 flex-col items-center gap-1 border-tint-subtle border-r pr-5">
download={file.name} <FileIcon contentType={contentType} className="size-5 text-primary" />
insights={{ <div className="text-hint text-xs">{getHumanFileSize(file.size)}</div>
type: 'link_click', </div>
link: { <div className="min-w-24 flex-1">
target: block.data.ref, <div className="text-base">
position: SiteInsightsLinkPosition.Content, <Link
}, href={file.downloadURL}
}} target="_blank"
className={tcls('group/file', 'flex', 'flex-row', 'items-center', 'px-5', 'py-3')} insights={insights}
> className="hover:underline"
<div >
className={tcls( {file.name}
'min-w-14', </Link>
'mr-5',
'pr-5',
'flex',
'flex-col',
'items-center',
'gap-1',
'border-r',
'border-tint-subtle'
)}
>
<div>
<FileIcon
contentType={contentType}
className={tcls('size-5', 'text-primary')}
/>
</div> </div>
<div <div className="text-sm opacity-9 dark:opacity-8">{contentType}</div>
className={tcls( </div>
'text-xs', <div className="flex shrink-0 flex-wrap gap-2">
'text-tint', <DownloadButton
'group-hover/file:text-tint-strong' icon="download"
)} size="xsmall"
variant="secondary"
downloadUrl={file.downloadURL}
filename={file.name}
insights={insights}
> >
{getHumanFileSize(file.size)} {t(language, 'download')}
</div> </DownloadButton>
<Button
icon="arrow-up-right-from-square"
size="xsmall"
variant="secondary"
href={file.downloadURL}
target="_blank"
insights={insights}
>
{t(language, 'open')}
</Button>
</div> </div>
<div> </div>
<div className={tcls('text-base')}>{file.name}</div>
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
{contentType}
</div>
</div>
</Link>
</Caption> </Caption>
); );
} }
@@ -9,7 +9,7 @@ export function FileIcon(props: { contentType: SimplifiedFileType | null; classN
const { contentType, className } = props; const { contentType, className } = props;
switch (contentType) { switch (contentType) {
case 'pdf': case 'PDF':
return <Icon icon="file-pdf" className={className} />; return <Icon icon="file-pdf" className={className} />;
case 'image': case 'image':
return <Icon icon="file-image" className={className} />; return <Icon icon="file-image" className={className} />;
@@ -1,6 +1,7 @@
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api'; import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
/** /**
@@ -28,6 +29,8 @@ export function HashLinkButton(props: {
'h-[1em]', 'h-[1em]',
'border-0', 'border-0',
'opacity-0', 'opacity-0',
'site-background',
'rounded',
'group-hover/hash:opacity-[0]', 'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]', 'group-focus/hash:opacity-[0]',
'md:group-hover/hash:opacity-[1]', 'md:group-hover/hash:opacity-[1]',
@@ -35,10 +38,10 @@ export function HashLinkButton(props: {
className className
)} )}
> >
<a <Link
href={`#${id}`} href={`#${id}`}
aria-label={label} aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)} className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
> >
<Icon <Icon
icon="hashtag" icon="hashtag"
@@ -52,7 +55,7 @@ export function HashLinkButton(props: {
iconClassName iconClassName
)} )}
/> />
</a> </Link>
</div> </div>
); );
} }
@@ -36,7 +36,10 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
<HashLinkButton <HashLinkButton
id={id} id={id}
block={block} block={block}
className={tcls('-ml-6', 'pr-2')} className={tcls(
'-ml-6 pr-2',
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')} iconClassName={tcls('size-4')}
label="Direct link to heading" label="Direct link to heading"
/> />
@@ -65,9 +65,9 @@ export function Hint({
{hasHeading ? ( {hasHeading ? (
<Block <Block
style={tcls( style={tcls(
'w-full items-start py-4! pl-3 text-[1em] *:flex-none', 'w-full items-start py-4! pl-3 text-[1em]! *:flex-none',
// Heading hash styles // Heading hash styles
'flip-heading-hash pr-8', 'flip-heading-hash pr-8 [&_.hash]:bg-transparent',
hintStyle.header hintStyle.header
)} )}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
@@ -19,34 +19,42 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
className={tcls( className={tcls(
style, style,
'flex', 'flex',
'flex-row', 'w-full',
'gap-3',
align === 'center' && 'justify-center', align === 'center' && 'justify-center',
align === 'right' && 'justify-end', align === 'right' && 'justify-end',
align === 'left' && 'justify-start', align === 'left' && 'justify-start'
hasMultipleImages && ['grid', 'grid-flow-col'],
withFrame && [
'rounded-2xl',
'border',
'border-[rgb(234,235,238)]',
'dark:border-[rgb(45,50,58)]',
'relative',
'overflow-hidden',
]
)} )}
> >
{block.nodes.map((node: any, _i: number) => ( <div
<ImageBlock className={tcls(
key={node.key} 'flex',
block={node} 'flex-row',
document={document} 'gap-3',
style={[]} hasMultipleImages && ['grid', 'grid-flow-col'],
siblings={block.nodes.length} withFrame && [
context={context} 'rounded-2xl',
isEstimatedOffscreen={isEstimatedOffscreen} 'border',
withFrame={withFrame} 'border-[rgb(234,235,238)]',
/> 'dark:border-[rgb(45,50,58)]',
))} 'relative',
'overflow-hidden',
'w-fit',
]
)}
>
{block.nodes.map((node: any, _i: number) => (
<ImageBlock
key={node.key}
block={node}
document={document}
style={[]}
siblings={block.nodes.length}
context={context}
isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
/>
))}
</div>
</div> </div>
); );
} }
@@ -135,8 +143,7 @@ async function ImageBlock(props: {
} }
: null, : null,
}} }}
priority={isEstimatedOffscreen ? 'lazy' : 'high'} loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
preload
zoom zoom
inlineStyle={{ inlineStyle={{
maxWidth: '100%', maxWidth: '100%',
@@ -1,31 +1,29 @@
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import * as api from '@gitbook/api'; import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons'; import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives'; import { Button } from '../primitives';
import type { InlineProps } from './Inline'; import type { InlineProps } from './Inline';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) { export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props; const { inline, context } = props;
if (!context.contentContext) { const resolved = context.contentContext
throw new Error('InlineButton requires a contentContext'); ? await resolveContentRef(inline.data.ref, context.contentContext)
} : null;
const resolved = await resolveContentRef(inline.data.ref, context.contentContext); const href = resolved?.href ?? resolveContentRefFallback(inline.data.ref)?.href;
if (!resolved) { const inlineElement = (
return null;
}
return (
// Set the leading to have some vertical space between adjacent buttons // Set the leading to have some vertical space between adjacent buttons
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2"> <span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
<Button <Button
href={resolved.href} href={href}
label={inline.data.label} label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons. // TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind} variant={inline.data.kind}
className="leading-normal" className="leading-normal"
disabled={href === undefined}
icon={inline.data.icon as IconName | undefined} icon={inline.data.icon as IconName | undefined}
insights={{ insights={{
type: 'link_click', type: 'link_click',
@@ -37,4 +35,10 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>)
/> />
</span> </span>
); );
if (!resolved) {
return <NotFoundRefHoverCard context={context}>{inlineElement}</NotFoundRefHoverCard>;
}
return inlineElement;
} }
@@ -1,4 +1,4 @@
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api'; import type { DocumentInlineIcon } from '@gitbook/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
@@ -7,16 +7,11 @@ import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) { export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props; const { inline } = props;
const icon = inline.data.icon as IconName; const { color, icon } = inline.data;
// @ts-expect-error remove this comment once API is updated
const color = inline.data.color
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
(inline.data.color as DocumentMarkColor['data']['text'])
: undefined;
return ( return (
<Icon <Icon
icon={icon} icon={icon as IconName}
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)} className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
/> />
); );
@@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
} }
: null, : null,
}} }}
priority="lazy" loading="lazy"
preload
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]} style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline inline
zoom={!isInLink} zoom={!isInLink}
@@ -1,12 +1,17 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; import { type ContentRef, type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server'; import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, languages } from '@/intl/translations'; import { type TranslationLanguage, languages } from '@/intl/translations';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives'; import { StyledLink } from '../../primitives';
import type { InlineProps } from '../Inline'; import type { InlineProps } from '../Inline';
import { Inlines } from '../Inlines'; import { Inlines } from '../Inlines';
import { NotFoundRefHoverCard } from '../NotFoundRefHoverCard';
import { InlineLinkTooltip } from './InlineLinkTooltip'; import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) { export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
@@ -18,52 +23,75 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
resolveAnchorText: false, resolveAnchorText: false,
}) })
: null; : null;
const { contentContext } = context; const { contentContext } = context;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en; const inlinesElement = (
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
);
if (!contentContext || !resolved) { if (!resolved) {
const fallback = resolveContentRefFallback(inline.data.ref);
return ( return (
<HoverCardRoot> <NotFoundRefHoverCard context={context}>
<HoverCardTrigger> {fallback ? (
<span className="cursor-not-allowed underline"> <InlineLinkAnchor href={fallback.href} contentRef={inline.data.ref} isExternal>
<Inlines {inlinesElement}
context={context} </InlineLinkAnchor>
document={document} ) : (
nodes={inline.nodes} <span className="cursor-not-allowed underline">{inlinesElement}</span>
ancestorInlines={[...ancestorInlines, inline]} )}
/> </NotFoundRefHoverCard>
</span>
</HoverCardTrigger>
<HoverCard className="flex flex-col gap-1 p-4">
<div className="flex items-center gap-2">
<Icon icon="ban" className="size-4 text-tint-subtle" />
<h5 className="font-semibold">{tString(language, 'notfound_title')}</h5>
</div>
<p className="text-sm text-tint">{tString(language, 'notfound_link')}</p>
</HoverCard>
</HoverCardRoot>
); );
} }
const isExternal = inline.data.ref.kind === 'url'; const anchorElement = (
const isMailto = resolved.href.startsWith('mailto:'); <InlineLinkAnchor
const content = (
<StyledLink
href={resolved.href} href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
);
if (context.withLinkPreviews) {
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
{anchorElement}
</InlineLinkTooltipWrapper>
);
}
return anchorElement;
}
function InlineLinkAnchor(props: {
href: string;
contentRef: ContentRef;
isExternal?: boolean;
children: React.ReactNode;
}) {
const { href, isExternal, contentRef, children } = props;
const isMailto = href.startsWith('mailto:');
return (
<StyledLink
href={href}
insights={{ insights={{
type: 'link_click', type: 'link_click',
link: { link: {
target: inline.data.ref, target: contentRef,
position: SiteInsightsLinkPosition.Content, position: SiteInsightsLinkPosition.Content,
}, },
}} }}
> >
<Inlines {children}
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isMailto ? ( {isMailto ? (
<Icon <Icon
icon="envelope" icon="envelope"
@@ -77,16 +105,6 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
) : null} ) : null}
</StyledLink> </StyledLink>
); );
if (context.withLinkPreviews) {
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
{content}
</InlineLinkTooltipWrapper>
);
}
return content;
} }
/** /**
@@ -1,37 +1,9 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import { tcls } from '@/lib/tailwind';
import React from 'react'; import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
const LoadingValueContext = React.createContext<React.ReactNode>(null);
// To avoid polluting the RSC payload with the tooltip implementation,
// we lazily load it on the client side. This way, the tooltip is only loaded
// when the user interacts with the link, and it doesn't block the initial render.
const InlineLinkTooltipImpl = dynamic(
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
{
// Disable server-side rendering for this component, it's only
// visible on user interaction.
ssr: false,
loading: () => {
// The fallback should be the children (the content of the link),
// but as next/dynamic is aiming for feature parity with React.lazy,
// it doesn't support passing children to the loading component.
// https://github.com/vercel/next.js/issues/7906
const children = React.useContext(LoadingValueContext);
return <>{children}</>;
},
}
);
/**
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
* and polluting the RSC payload.
*
* The link text and href have already been rendered on the server for good SEO,
* so we can be as lazy as possible with the tooltip.
*/
export function InlineLinkTooltip(props: { export function InlineLinkTooltip(props: {
isSamePage: boolean; isSamePage: boolean;
isExternal: boolean; isExternal: boolean;
@@ -45,28 +17,79 @@ export function InlineLinkTooltip(props: {
openInNewTabLabel: string; openInNewTabLabel: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { children, ...rest } = props; const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
const [shouldLoad, setShouldLoad] = React.useState(false);
// Once the browser is idle, we set shouldLoad to true. return (
// NOTE: to be slightly more performant, we could load when a link is hovered. <HoverCardRoot>
// But I found this was too much of a delay for the tooltip to appear. <HoverCardTrigger>{children}</HoverCardTrigger>
// Loading on idle is a good compromise, as it allows the initial render to be fast, <HoverCard className="p-4">
// while still loading the tooltip in the background and not polluting the RSC payload. <div className="flex items-start gap-4">
React.useEffect(() => { <div className="flex flex-col">
if ('requestIdleCallback' in window) { {breadcrumbs && breadcrumbs.length > 0 ? (
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true)); <div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
} else { {breadcrumbs.map((crumb, index) => {
// fallback for old browsers const Tag = crumb.href ? StyledLink : 'div';
setTimeout(() => setShouldLoad(true), 2000);
}
}, []);
return shouldLoad ? ( return (
<LoadingValueContext.Provider value={children}> <Fragment key={crumb.label}>
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl> {index !== 0 ? (
</LoadingValueContext.Provider> <Icon
) : ( icon="chevron-right"
children className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
</HoverCardRoot>
); );
} }
@@ -1,95 +0,0 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
export function InlineLinkTooltipImpl(props: {
isSamePage: boolean;
isExternal: boolean;
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
</HoverCardRoot>
);
}
@@ -32,8 +32,6 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls( blockStyle={tcls(
'min-h-lh', 'min-h-lh',
// flip heading hash icon if list item is a heading
'flip-heading-hash',
// remove margin-top for the first heading in a list // remove margin-top for the first heading in a list
'[h2]:pt-0', '[h2]:pt-0',
'[h3]:pt-0', '[h3]:pt-0',
@@ -0,0 +1,32 @@
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { Icon } from '@gitbook/icons';
import type { DocumentContextProps } from '../DocumentView';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
/**
* Hover card displayed for a link not found.
*/
export function NotFoundRefHoverCard(
props: DocumentContextProps & {
children: React.ReactNode;
}
) {
const {
context: { contentContext },
children,
} = props;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="flex flex-col gap-1 p-4">
<div className="flex items-center gap-2">
<Icon icon="ban" className="size-4 text-tint-subtle" />
<h5 className="font-semibold">{tString(language, 'notfound_title')}</h5>
</div>
<p className="text-sm text-tint">{tString(language, 'notfound_link')}</p>
</HoverCard>
</HoverCardRoot>
);
}
@@ -39,6 +39,9 @@ export function getOpenAPIContext(args: {
chevronDown: <Icon icon="chevron-down" />, chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />, chevronRight: <Icon icon="chevron-right" />,
plus: <Icon icon="plus" />, plus: <Icon icon="plus" />,
copy: <Icon icon="copy" />,
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
}, },
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />, renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderDocument: (documentProps) => ( renderDocument: (documentProps) => (
@@ -34,7 +34,7 @@
.openapi-deprecated, .openapi-deprecated,
.openapi-stability { .openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
} }
.openapi-stability-alpha { .openapi-stability-alpha {
@@ -72,7 +72,7 @@
} }
.openapi-markdown code { .openapi-markdown code {
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
} }
.openapi-markdown pre code { .openapi-markdown pre code {
@@ -95,7 +95,7 @@
/* Method Tags */ /* Method Tags */
.openapi-method, .openapi-method,
.openapi-statuscode { .openapi-statuscode {
@apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap; @apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
} }
.openapi-method-get, .openapi-method-get,
@@ -270,11 +270,11 @@
} }
.openapi-schema-enum-value:first-child { .openapi-schema-enum-value:first-child {
@apply rounded-l ml-0; @apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
} }
.openapi-schema-enum-value:last-child { .openapi-schema-enum-value:last-child {
@apply rounded-r; @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
} }
/* Schema Description */ /* Schema Description */
@@ -308,7 +308,7 @@
.openapi-schema-pattern code, .openapi-schema-pattern code,
.openapi-schema-enum-value code, .openapi-schema-enum-value code,
.openapi-schema-default code { .openapi-schema-default code {
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
} }
/* Authentication */ /* Authentication */
@@ -325,16 +325,20 @@
@apply prose *:!prose-sm *:text-tint; @apply prose *:!prose-sm *:text-tint;
} }
.openapi-securities-oauth-content {
@apply flex flex-col gap-1 mt-1;
}
.openapi-securities-oauth-content.openapi-markdown code { .openapi-securities-oauth-content.openapi-markdown code {
@apply text-xs; @apply text-xs;
} }
.openapi-securities-scopes ul { .openapi-securities-scopes ul {
@apply !my-0; @apply !my-0 ml-4 pl-0;
} }
.openapi-securities-url { .openapi-securities-url {
@apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors; @apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
} }
.openapi-securities-body { .openapi-securities-body {
@@ -394,15 +398,13 @@
@apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit; @apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit;
} }
.openapi-disclosure-group-trigger[aria-expanded="false"] { .openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown {
.openapi-response-description.openapi-markdown { @apply truncate;
@apply truncate; @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
@apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0; }
}
.openapi-response-tab-content { .openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content {
@apply basis-[60%] @apply basis-[60%];
}
} }
.openapi-response-body { .openapi-response-body {
@@ -454,6 +456,10 @@
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong; @apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
} }
.openapi-codesample-header-content .openapi-path-title {
@apply block;
}
.openapi-codesample-header-content .openapi-path .openapi-path-variable { .openapi-codesample-header-content .openapi-path .openapi-path-variable {
@apply text-[0.813rem]; @apply text-[0.813rem];
} }
@@ -474,21 +480,26 @@
} }
.openapi-path-variable { .openapi-path-variable {
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:content-none! after:!content-none; @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
} }
.openapi-path-server { .openapi-path-server {
@apply text-tint hidden md:inline; @apply text-tint inline;
} }
.openapi-path .openapi-method { .openapi-summary .openapi-path .openapi-method {
@apply m-0 mt-0.5 items-center flex px-1; @apply m-0 items-center flex px-2 py-1 h-6;
} }
.openapi-path-title { .openapi-path-title {
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10; @apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
@apply whitespace-nowrap md:whitespace-normal; @apply whitespace-nowrap md:whitespace-normal;
scrollbar-width: none;
-ms-overflow-style: none;
}
.openapi-path-title-row{
@apply flex flex-row items-center;
} }
.openapi-path-title[data-deprecated="true"] { .openapi-path-title[data-deprecated="true"] {
@@ -515,7 +526,7 @@
.openapi-panel, .openapi-panel,
.openapi-codesample, .openapi-codesample,
.openapi-response-examples { .openapi-response-examples {
@apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; @apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
} }
.openapi-response-examples-panel { .openapi-response-examples-panel {
@@ -592,18 +603,30 @@ body:has(.openapi-select-popover) {
} }
.openapi-select > button { .openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint; @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
@apply hover:bg-tint-hover transition-all; @apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
.openapi-select:not(.openapi-select-unstyled) > button {
@apply border border-tint-subtle bg-tint text-xs;
}
.openapi-select-unstyled > button {
@apply p-1 *:truncate max-w-full;
} }
.openapi-select > button[data-focused="true"] { .openapi-select > button[data-focused="true"] {
@apply outline-primary -outline-offset-1 outline outline-1; @apply outline-primary -outline-offset-1 outline;
} }
.openapi-select > button > span.react-aria-SelectValue { .openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate flex items-center; @apply shrink truncate flex items-center;
} }
.openapi-select > button > .react-aria-SelectValue [slot="description"] {
display: none;
}
.openapi-select > button .openapi-markdown { .openapi-select > button .openapi-markdown {
@apply *:leading-none; @apply *:leading-none;
} }
@@ -613,7 +636,7 @@ body:has(.openapi-select-popover) {
} }
.openapi-select-popover { .openapi-select-popover {
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none; @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1; @apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
} }
@@ -626,10 +649,18 @@ body:has(.openapi-select-popover) {
} }
.openapi-select-item { .openapi-select-item {
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded !outline-none; @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current; @apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
} }
.openapi-select-item.openapi-select-item-column {
@apply flex flex-col gap-1 justify-start items-start;
}
.openapi-select-item [slot="description"] {
@apply text-xs text-tint-subtle;
}
.openapi-select button .openapi-markdown, .openapi-select button .openapi-markdown,
.openapi-select-item .openapi-markdown { .openapi-select-item .openapi-markdown {
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden; @apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
@@ -714,7 +745,7 @@ body:has(.openapi-select-popover) {
} }
.openapi-tabs-tab { .openapi-tabs-tab {
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded; @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
} }
.openapi-tabs-tab[aria-selected="true"] { .openapi-tabs-tab[aria-selected="true"] {
@@ -785,12 +816,12 @@ body:has(.openapi-select-popover) {
} }
.openapi-schemas-disclosure > .openapi-disclosure-trigger { .openapi-schemas-disclosure > .openapi-disclosure-trigger {
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1; @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
} }
.openapi-schemas-disclosure > .openapi-disclosure-trigger, .openapi-schemas-disclosure > .openapi-disclosure-trigger,
.openapi-schemas-disclosure .openapi-disclosure-panel { .openapi-schemas-disclosure .openapi-disclosure-panel {
@apply straight-corners:!rounded-none; @apply straight-corners:!rounded-none circular-corners:!rounded-md;
} }
.openapi-disclosure-panel { .openapi-disclosure-panel {
@@ -818,7 +849,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure, .openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) { ) {
@apply rounded-xl; @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
} }
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure { .openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -833,10 +864,10 @@ body:has(.openapi-select-popover) {
@apply ring-1 shadow-sm; @apply ring-1 shadow-sm;
} }
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) { .openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) {
@apply mt-2; @apply mt-2;
} }
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) { .openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) {
@apply mb-2; @apply mb-2;
} }
@@ -966,3 +997,43 @@ body:has(.openapi-select-popover) {
.openapi-copy-button[data-disabled="true"] { .openapi-copy-button[data-disabled="true"] {
@apply cursor-default; @apply cursor-default;
} }
.openapi-path-copy-button {
@apply p-1 flex rounded-md straight-corners:rounded-none;
@apply hover:bg-tint dark:hover:bg-tint-hover;
}
.openapi-path-copy-button-icon {
@apply size-6 opacity-0 transition-all hidden sm:flex;
}
.openapi-path:hover .openapi-path-copy-button-icon {
@apply opacity-100;
}
.openapi-path-copy-button-icon svg {
@apply text-tint size-4;
}
.openapi-required-scopes {
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
}
.openapi-required-scopes .openapi-required-scopes-header {
@apply flex items-center gap-3;
}
.openapi-required-scopes .openapi-required-scopes-header svg {
@apply size-3.5 text-tint-subtle rotate-none;
}
.openapi-required-scopes .openapi-disclosure-group-panel {
@apply px-3 pb-3;
}
.openapi-required-scopes .openapi-securities-scopes {
@apply ml-6 font-normal *:!text-[0.8125rem];
}
.openapi-required-scopes .openapi-required-scopes-description {
@apply text-xs !text-tint font-normal mb-2;
}
@@ -60,7 +60,7 @@ export async function RecordCard(
'overflow-hidden', 'overflow-hidden',
'[&_.heading>div:first-child]:hidden', '[&_.heading>div:first-child]:hidden',
'[&_.heading>div]:text-[.8em]', '[&_.heading>div]:text-[.8em]',
'md:[&_.heading>div]:text-[1em]', '@xl:[&_.heading>div]:text-[1em]',
'[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card '[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
// On mobile, check if we can display the cover responsively or not: // On mobile, check if we can display the cover responsively or not:
@@ -69,10 +69,10 @@ export async function RecordCard(
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [ ? [
lightCoverIsSquareOrPortrait lightCoverIsSquareOrPortrait
? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]' ? '@sm:grid-cols-none grid-cols-[40%__1fr] @sm:grid-rows-[auto_1fr]'
: '', : '',
darkCoverIsSquareOrPortrait darkCoverIsSquareOrPortrait
? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]' ? 'dark:@sm:grid-cols-none dark:grid-cols-[40%__1fr] dark:@sm:grid-rows-[auto_1fr]'
: '', : '',
].filter(Boolean) ].filter(Boolean)
: 'grid-rows-[auto_1fr]' : 'grid-rows-[auto_1fr]'
@@ -85,11 +85,13 @@ export async function RecordCard(
light: { light: {
src: lightCover.href, src: lightCover.href,
size: lightCover.file?.dimensions, size: lightCover.file?.dimensions,
alt: light.alt,
}, },
dark: darkCover dark: darkCover
? { ? {
src: darkCover.href, src: darkCover.href,
size: darkCover.file?.dimensions, size: darkCover.file?.dimensions,
alt: dark.alt,
} }
: null, : null,
}} }}
@@ -110,18 +112,15 @@ export async function RecordCard(
'bg-tint-subtle', 'bg-tint-subtle',
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [ ? [
lightCoverIsSquareOrPortrait lightCoverIsSquareOrPortrait ? '@sm:aspect-video @sm:h-auto' : '',
? 'min-[432px]:aspect-video min-[432px]:h-auto'
: '',
darkCoverIsSquareOrPortrait darkCoverIsSquareOrPortrait
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto' ? 'dark:@sm:aspect-video dark:@sm:h-auto'
: '', : '',
].filter(Boolean) ].filter(Boolean)
: ['h-auto', 'aspect-video'], : ['h-auto', 'aspect-video'],
objectFits objectFits
)} )}
priority={isOffscreen ? 'lazy' : 'high'} loading={isOffscreen ? 'lazy' : 'eager'}
preload
/> />
) : null} ) : null}
<div <div
@@ -229,7 +229,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}, },
}, },
}} }}
priority="lazy" loading="lazy"
/> />
) : ( ) : (
<FileIcon <FileIcon
@@ -404,7 +404,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
size: image.file?.dimensions, size: image.file?.dimensions,
}, },
}} }}
priority="lazy" loading="lazy"
/> />
{image.text} {image.text}
</StyledLink> </StyledLink>
@@ -15,8 +15,8 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
'inline-grid', 'inline-grid',
'gap-4', 'gap-4',
'grid-cols-1', 'grid-cols-1',
'min-[432px]:grid-cols-2', '@sm:grid-cols-2',
view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3', view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3',
block.data.fullWidth ? 'large:flex-column' : null block.data.fullWidth ? 'large:flex-column' : null
)} )}
> >
@@ -21,6 +21,12 @@ export function getRecordValue<T extends number | string | boolean | string[] |
return record.values[definitionId]; return record.values[definitionId];
} }
type RecordCardCover = {
contentRef: ContentRefFile | ContentRefURL | null;
objectFit?: CardsImageObjectFit;
alt?: string;
};
/** /**
* Get the covers for a record card. * Get the covers for a record card.
* Returns both the light and dark covers with their content refs and optional object fit. * Returns both the light and dark covers with their content refs and optional object fit.
@@ -31,10 +37,7 @@ export function getRecordCardCovers(
record: DocumentTableRecord, record: DocumentTableRecord,
view: DocumentTableViewCards view: DocumentTableViewCards
): { ): {
[key in 'light' | 'dark']: { [key in 'light' | 'dark']: RecordCardCover;
contentRef: ContentRefFile | ContentRefURL | null;
objectFit?: CardsImageObjectFit;
};
} { } {
const lightValue = view.coverDefinition const lightValue = view.coverDefinition
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[]) ? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
@@ -53,10 +56,9 @@ export function getRecordCardCovers(
/** /**
* Process a cover value and return the content ref and object fit. * Process a cover value and return the content ref and object fit.
*/ */
function processCoverValue(value: DocumentTableImageRecord | string[] | null | undefined): { function processCoverValue(
contentRef: ContentRefFile | ContentRefURL | null; value: DocumentTableImageRecord | string[] | null | undefined
objectFit?: CardsImageObjectFit; ): RecordCardCover {
} {
if (!value) { if (!value) {
return { contentRef: null }; return { contentRef: null };
} }
@@ -78,6 +80,7 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
return { return {
contentRef: imageValue.ref, contentRef: imageValue.ref,
objectFit: imageValue.objectFit, objectFit: imageValue.objectFit,
alt: imageValue.alt,
}; };
} }
@@ -1,12 +1,22 @@
'use client'; 'use client';
import React, { useCallback, useMemo } from 'react'; import React, {
memo,
useCallback,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
} from 'react';
import { useHash, useIsMounted } from '@/components/hooks'; import { NavigationStatusContext, useListOverflow } from '@/components/hooks';
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import { type ClassValue, tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import type { DocumentBlockTabs } from '@gitbook/api'; import { Icon } from '@gitbook/icons';
import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton'; import { useRouter } from 'next/navigation';
interface TabsState { interface TabsState {
activeIds: { activeIds: {
@@ -46,16 +56,9 @@ const TITLES_MAX = 5;
export interface TabsItem { export interface TabsItem {
id: string; id: string;
title: string; title: string;
body: React.ReactNode;
} }
type SelectorMapper<Type> = {
[Property in keyof Type]: Type[Property];
};
type TabsInput = {
id: string;
tabs: SelectorMapper<TabsItem>[];
};
interface TabsState { interface TabsState {
activeIds: { activeIds: {
[tabsBlockId: string]: string; [tabsBlockId: string]: string;
@@ -66,16 +69,16 @@ interface TabsState {
/** /**
* Client side component for the tabs, taking care of interactions. * Client side component for the tabs, taking care of interactions.
*/ */
export function DynamicTabs( export function DynamicTabs(props: {
props: TabsInput & { id: string;
tabsBody: React.ReactNode[]; tabs: TabsItem[];
style: ClassValue; className?: string;
block: DocumentBlockTabs; }) {
} const { id, tabs, className } = props;
) { const router = useRouter();
const { id, block, tabs, tabsBody, style } = props;
const hash = useHash(); const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
const [initialized, setInitialized] = useState(false);
const [tabsState, setTabsState] = useTabsState(); const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => { const activeState = useMemo(() => {
const input = { id, tabs }; const input = { id, tabs };
@@ -84,41 +87,99 @@ export function DynamicTabs(
); );
}, [id, tabs, tabsState]); }, [id, tabs, tabsState]);
// Track if the tab has been touched by the user.
const touchedRef = useRef(false);
// To avoid issue with hydration, we only use the state from localStorage // To avoid issue with hydration, we only use the state from localStorage
// once the component has been mounted. // once the component has been initialized (=mounted).
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state. // and get stuck into an inconsistent state.
const mounted = useIsMounted(); const active = initialized ? activeState : tabs[0];
const active = mounted ? activeState : tabs[0];
/** // When clicking to select a tab, we:
* When clicking to select a tab, we: // - update the URL hash
* - mark this specific ID as selected // - mark this specific ID as selected
* - store the ID to auto-select other tabs with the same title // - store the ID to auto-select other tabs with the same title
*/ const selectTab = useCallback(
const onSelectTab = React.useCallback( (tabId: string, manual = true) => {
(tab: TabsItem) => { const tab = tabs.find((tab) => tab.id === tabId);
setTabsState((prev) => ({
activeIds: { if (!tab) {
...prev.activeIds, return;
[id]: tab.id, }
},
activeTitles: tab.title if (manual) {
? prev.activeTitles touchedRef.current = true;
.filter((t) => t !== tab.title) const href = `#${tab.id}`;
.concat([tab.title]) if (window.location.hash !== href) {
.slice(-TITLES_MAX) onNavigationClick(href);
: prev.activeTitles, router.replace(href, { scroll: false });
})); }
}
setTabsState((prev) => {
if (prev.activeIds[id] === tab.id) {
return prev;
}
return {
activeIds: {
...prev.activeIds,
[id]: tab.id,
},
activeTitles: tab.title
? prev.activeTitles
.filter((t) => t !== tab.title)
.concat([tab.title])
.slice(-TITLES_MAX)
: prev.activeTitles,
};
});
}, },
[id, setTabsState] [router, setTabsState, tabs, id]
); );
/** // When the hash changes, we try to select the tab containing the targetted element.
* When the hash changes, we try to select the tab containing the targetted element. React.useLayoutEffect(() => {
*/ setInitialized(true);
React.useEffect(() => {
if (!hash) { if (hash) {
// First check if the hash matches a tab ID.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
selectTab(hash, false);
return;
}
// Then check if the hash matches an element inside a tab.
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
const tabPanel = activeElement.closest('[role="tabpanel"]');
if (!tabPanel) {
return;
}
selectTab(tabPanel.id, false);
}
}, [selectTab, tabs, hash]);
// Scroll to active element in the tab.
React.useLayoutEffect(() => {
// If there is no hash or active tab, nothing to scroll.
if (!hash || hash !== '' || !active) {
return;
}
// If the tab is touched, we don't want to scroll.
if (touchedRef.current) {
return;
}
// If the hash matches a tab, then the scroll is already done.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
return; return;
} }
@@ -127,143 +188,237 @@ export function DynamicTabs(
return; return;
} }
const tabAncestor = activeElement.closest('[role="tabpanel"]'); activeElement.scrollIntoView({
if (!tabAncestor) { block: 'start',
return; behavior: 'instant',
} });
}, [active, tabs, hash]);
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
if (!tab) {
return;
}
onSelectTab(tab);
}, [hash, tabs, onSelectTab]);
return ( return (
<div <div
className={tcls( className={tcls(
'rounded-lg', 'rounded-lg',
'straight-corners:rounded-xs', 'straight-corners:rounded-xs',
'ring-1', 'ring-1 ring-tint-subtle ring-inset',
'ring-inset', 'flex min-w-0 flex-col',
'ring-tint-subtle', className
'flex',
'flex-col',
'overflow-hidden',
style
)} )}
> >
<div <TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
role="tablist" {tabs.map((tab) => (
className={tcls( <TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
'group/tabs',
'inline-flex',
'flex-row',
'self-stretch',
'after:flex-1',
'after:bg-tint-12/1',
// if last tab is selected, apply rounded to :after element
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
)}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={tcls(
hashLinkButtonWrapperStyles,
'flex',
'items-center',
'gap-3.5',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
//next from active-tab
'[.active-tab+&]:rounded-bl-md',
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'after:transition-colors',
'after:border-r',
'after:absolute',
'after:left-[unset]',
'after:right-0',
'after:border-tint',
'after:top-[15%]',
'after:h-[70%]',
'after:w-px',
'px-3.5',
'py-2',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'max-w-full',
'truncate',
active?.id === tab.id
? [
'shrink-0',
'active-tab',
'text-tint-strong',
'bg-transparent',
'[&.active-tab]:after:border-transparent',
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null
)}
>
<button
type="button"
role="tab"
aria-selected={active?.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
onSelectTab(tab);
}}
className={tcls(
'inline-block',
'text-sm',
'transition-[color]',
'font-medium',
'relative',
'max-w-full',
'truncate'
)}
>
{tab.title}
</button>
<HashLinkButton
id={getTabButtonId(tab.id)}
block={block}
label="Direct link to tab"
/>
</div>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active?.id ? 'hidden' : null)}
>
{tabsBody[index]}
</div>
))} ))}
</div> </div>
); );
} }
const TabPanel = memo(function TabPanel(props: {
tab: TabsItem;
isActive: boolean;
}) {
const { tab, isActive } = props;
return (
<div
role="tabpanel"
id={tab.id}
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4" hidden={!isActive}>
{tab.body}
</div>
</div>
);
});
const TabItemList = memo(function TabItemList(props: {
tabs: TabsItem[];
activeTabId: string | null;
onSelect: (tabId: string) => void;
}) {
const { tabs, activeTabId, onSelect } = props;
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
const overflowingTabs = useMemo(
() =>
Array.from(overflowing, (id) => {
const tabId = getTabIdFromButtonId(id);
return tabs.find((tab) => tab.id === tabId);
}).filter((x) => x !== undefined),
[overflowing, tabs]
);
return (
<div
ref={containerRef}
role="tablist"
className={tcls(
'group/tabs',
'overflow-hidden',
'rounded-t-lg',
'straight-corners:rounded-t-xs',
'inline-flex',
'self-stretch',
'after:flex-1',
'after:bg-tint-12/1',
// if last tab is selected, apply rounded to :after element
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
)}
>
{/* When we measure, we add the menu at start to be sure everything's fit. */}
{isMeasuring ? (
<TabsDropdownMenu tabs={tabs} onSelect={onSelect} activeTabId={activeTabId} />
) : null}
{tabs.map((tab) => {
// Hide overflowing tabs when not measuring.
if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) {
return null;
}
return (
<TabItem
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
tab={tab}
onSelect={onSelect}
/>
);
})}
{/* Dropdown for overflowing tabs */}
{overflowingTabs.length > 0 && !isMeasuring ? (
<TabsDropdownMenu
tabs={overflowingTabs}
onSelect={onSelect}
activeTabId={activeTabId}
/>
) : null}
</div>
);
});
function TabsDropdownMenu(props: {
tabs: TabsItem[];
activeTabId: string | null;
onSelect: (tabId: string) => void;
}) {
const { tabs, onSelect, activeTabId } = props;
const language = useLanguage();
return (
<DropdownMenu
button={
<TabButton
isActive={tabs.some((tab) => tab.id === activeTabId)}
aria-label={tString(language, 'more')}
className="shrink-0"
>
<Icon icon="ellipsis" className="size-4" />
</TabButton>
}
>
{tabs.map((tab) => {
return (
<DropdownMenuItem
key={tab.id}
onClick={() => onSelect(tab.id)}
active={tab.id === activeTabId}
>
{tab.title}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
/**
* Tab item that accepts a `tab` prop.
*/
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, onSelect } = props;
return (
<TabButton
ref={ref}
role="tab"
aria-selected={isActive}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
onClick={() => onSelect(tab.id)}
>
{tab.title}
</TabButton>
);
});
/**
* Generic tab button component, low-level.
*/
function TabButton(
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
isActive?: boolean;
}
) {
const { isActive, ...rest } = props;
return (
<div
className={tcls(
'relative',
'flex items-center',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
//next from active-tab
'[.active-tab+&]:rounded-bl-md',
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'after:transition-colors',
'after:border-r',
'after:absolute',
'after:left-[unset]',
'after:right-0',
'after:border-tint',
'after:top-[15%]',
'after:h-[70%]',
'after:w-px',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'max-w-full',
'shrink-0',
'truncate',
props['aria-selected'] || props['aria-expanded'] || isActive
? [
'active-tab',
'text-tint-strong',
'bg-transparent',
'[&.active-tab]:after:border-transparent',
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null
)}
>
<button
{...rest}
type="button"
className={tcls(
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
props.className
)}
/>
</div>
);
}
/** /**
* Get the ID for a tab button. * Get the ID for a tab button.
*/ */
@@ -272,17 +427,25 @@ function getTabButtonId(tabId: string) {
} }
/** /**
* Get the ID for a tab panel. * Get the ID of a tab from a button ID.
* We use the ID of the tab itself as links can be pointing to this ID.
*/ */
function getTabPanelId(tabId: string) { function getTabIdFromButtonId(buttonId: string) {
return tabId; if (buttonId.startsWith('tab-')) {
return buttonId.slice(4);
}
return buttonId;
} }
/** /**
* Get explicitly selected tab in a set of tabs. * Get explicitly selected tab in a set of tabs.
*/ */
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null { function getTabBySelection(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
const activeId = state.activeIds[input.id]; const activeId = state.activeIds[input.id];
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null; return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
} }
@@ -290,7 +453,13 @@ function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null
/** /**
* Get the best selected tab in a set of tabs by taking only title into account. * Get the best selected tab in a set of tabs by taking only title into account.
*/ */
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null { function getTabByTitle(
input: {
id: string;
tabs: TabsItem[];
},
state: TabsState
): TabsItem | null {
return ( return (
input.tabs input.tabs
.map((item) => { .map((item) => {
@@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs';
export function Tabs(props: BlockProps<DocumentBlockTabs>) { export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const { block, ancestorBlocks, document, style, context } = props; const { block, ancestorBlocks, document, style, context } = props;
const tabs: TabsItem[] = []; if (!block.key) {
const tabsBody: React.ReactNode[] = []; throw new Error('Tabs block is missing a key');
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.meta?.id ?? tab.key!,
title: tab.data.title ?? '',
});
tabsBody.push(
<Blocks
key={tab.key ?? index}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
context={context}
blockStyle={tcls('flip-heading-hash')}
style={tcls('w-full', 'space-y-4')}
/>
);
});
if (context.mode === 'print') {
// When printing, we display the tab, one after the other
return (
<>
{tabs.map((tab, index) => (
<DynamicTabs
key={tab.id}
id={block.key!}
block={block}
tabs={[tab]}
tabsBody={[tabsBody[index]]}
style={style}
/>
))}
</>
);
} }
return ( const id = block.key;
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
); const tabs: TabsItem[] = block.nodes.map((tab) => {
if (!tab.key) {
throw new Error('Tab block is missing a key');
}
return {
id: tab.meta?.id ?? tab.key,
title: tab.data.title ?? '',
body: (
<Blocks
key={tab.key}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
context={context}
blockStyle="flip-heading-hash"
style="w-full space-y-4"
/>
),
};
});
// When printing, we display the tab, one after the other
if (context.mode === 'print') {
return tabs.map((tab) => {
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
});
}
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
} }
@@ -0,0 +1,82 @@
import { formatDateFull, formatDateShort, formatNumericDate } from '@/components/utils/dates';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockUpdate, DocumentBlockUpdates } from '@gitbook/api';
import { assert } from 'ts-essentials';
import type { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function Update(props: BlockProps<DocumentBlockUpdate>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
// Get the parent Updates block to retrieve properties from
const parentUpdates = ancestorBlocks.find(
(ancestor): ancestor is DocumentBlockUpdates => ancestor.type === 'updates'
);
if (!parentUpdates) {
assert(parentUpdates, 'Parent updates block should exist');
return null;
}
// Get the date for this Update block and parse it to a Date object
const date = block.data.date;
const parsedDate = parseISODate(date);
if (!parsedDate) {
assert(date, 'Date should exist on Update block');
return null;
}
// Then get the format from the parent Updates block and use that format
const dateFormat = parentUpdates.data?.format ?? 'full';
const displayDate = {
numeric: formatNumericDate(parsedDate),
full: formatDateFull(parsedDate),
short: formatDateShort(parsedDate),
}[dateFormat];
const isSticky = parentUpdates.data.sticky;
return (
<div
className={tcls(
'relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20',
style
)}
>
<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)]!'
)}
>
<time
// Adding a dateTime attribute for accessibility (and SEO)
dateTime={date}
className="inline-flex items-center font-medium text-neutral-10 text-sm tracking-wide"
>
{displayDate}
</time>
</div>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
// Remove padding-top from headings when they're the first child (similar to column-first-of-type pattern)
style="[&>*:first-child]:!pt-0 flex-1 space-y-4"
/>
</div>
);
}
/**
* Parse an ISO date string into a Date object.
*/
function parseISODate(value: string): Date | undefined {
if (!value) {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
@@ -0,0 +1,16 @@
import type { DocumentBlockUpdates } from '@gitbook/api';
import type { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function Updates(props: BlockProps<DocumentBlockUpdates>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[style, 'flex flex-col gap-20']}
/>
);
}
@@ -19,19 +19,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
}; };
case 'heading-1': case 'heading-1':
return { return {
textSize: 'text-3xl font-semibold', textSize: 'text-xl @xs:text-2xl @lg:text-3xl font-semibold',
lineHeight: 'leading-tight', lineHeight: 'leading-tight',
marginTop: 'column-first-of-type:pt-0 pt-[1em]', marginTop: 'column-first-of-type:pt-0 pt-[1em]',
}; };
case 'heading-2': case 'heading-2':
return { return {
textSize: 'text-2xl font-semibold', textSize: 'text-lg @xs:text-xl @lg:text-2xl font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: 'column-first-of-type:pt-0 pt-[0.75em]', marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
}; };
case 'heading-3': case 'heading-3':
return { return {
textSize: 'text-xl font-semibold', textSize: 'text-base @xs:text-lg @lg:text-xl font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: 'column-first-of-type:pt-0 pt-[0.5em]', marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
}; };
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
<div <div
{...divProps} {...divProps}
className={tcls( className={tcls(
'flex h-full grow animate-fade-in-slow flex-col overflow-hidden bg-tint-base text-sm text-tint', 'flex h-full grow flex-col overflow-hidden bg-tint-base text-sm text-tint',
divProps.className divProps.className
)} )}
ref={ref} ref={ref}
+109 -98
View File
@@ -29,112 +29,123 @@ export function Footer(props: { context: GitBookSiteContext }) {
className={tcls( className={tcls(
'border-tint-subtle border-t', 'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens // If the footer only contains a mode toggle, we only show it on smaller screens
mobileOnly ? 'xl:hidden' : null mobileOnly ? '@7xl:hidden' : null
)} )}
> >
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}> <div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div <div
className={tcls( className={tcls(
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!', CONTAINER_STYLE,
'grid-cols-[auto_auto]', 'px-4',
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]', 'py-8',
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]', '@4xl:py-12',
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]', 'mx-auto',
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]', '@container/footer'
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'lg:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)} )}
> >
{ <div
// Footer Logo className={tcls(
customization.footer.logo ? ( 'mx-auto flex @xs:grid @4xl:max-w-none! max-w-3xl site-width-wide:max-w-screen-2xl flex-col justify-between gap-12',
<div className="col-start-1 row-start-1"> 'grid-cols-[auto_auto]',
<Image '@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
alt="Logo" '@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
resize={context.imageResizer} '@4xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
sources={{ '@7xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
light: { '@4xl:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
src: customization.footer.logo.light, '@7xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
}, '@4xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
dark: customization.footer.logo.dark '@7xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
? { )}
src: customization.footer.logo.dark, >
} {
: null, // Footer Logo
}} customization.footer.logo ? (
priority="lazy" <div className="col-start-1 row-start-1">
style={[ <Image
'w-auto', alt="Logo"
'max-w-40', resize={context.imageResizer}
'lg:max-w-64', sources={{
'max-h-10', light: {
'lg:max-h-12', src: customization.footer.logo.light,
'object-contain', },
'object-left', dark: customization.footer.logo.dark
'rounded-sm', ? {
'straight-corners:rounded-xs', src: customization.footer.logo.dark,
]} }
sizes={[ : null,
{ }}
width: 320, loading="lazy"
}, style={[
]} 'w-auto',
/> 'max-w-40',
</div> '@4xl:max-w-64',
) : null 'max-h-10',
} '@4xl:max-h-12',
'object-contain',
{ 'object-left',
// Theme Toggle 'rounded-sm',
customization.themes.toggeable ? ( 'straight-corners:rounded-xs',
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden"> ]}
<React.Suspense fallback={null}> sizes={[
<ThemeToggler /> {
</React.Suspense> width: 320,
</div> },
) : null ]}
} />
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'col-span-2 lg:page-has-toc:col-span-1 lg:page-has-toc:col-start-2 xl:page-no-toc:col-span-1 xl:page-no-toc:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex flex-1 grow flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
</div> </div>
</div> ) : null
) : null }
}
{ {
// Legal // Theme Toggle
customization.footer.copyright ? ( customization.themes.toggeable ? (
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs"> <div className="-col-start-2 row-start-1 flex items-start @xs:justify-end xl:hidden">
<p>{customization.footer.copyright}</p> <React.Suspense fallback={null}>
</div> <ThemeToggler />
) : null </React.Suspense>
} </div>
) : null
}
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'@4xl:page-has-toc:col-span-1 @7xl:page-no-toc:col-span-1 col-span-2 @4xl:page-has-toc:col-start-2 @7xl:page-no-toc:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl @xl:flex-row flex-col @xl:gap-6 gap-10">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex flex-1 grow flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
</div>
</div>
) : null
}
{
// Legal
customization.footer.copyright ? (
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
<p>{customization.footer.copyright}</p>
</div>
) : null
}
</div>
</div> </div>
</div> </div>
</footer> </footer>
@@ -51,6 +51,7 @@ export function CurrentContentIcon(
}, },
} }
} }
preload
{...imageProps} {...imageProps}
/> />
); );
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { getSpaceLanguage, t } from '@/intl/server'; import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import type { SiteSpace } from '@gitbook/api';
import { SearchContainer } from '../Search'; import { SearchContainer } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections'; import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink'; import { HeaderLink } from './HeaderLink';
@@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown';
export function Header(props: { export function Header(props: {
context: GitBookSiteContext; context: GitBookSiteContext;
withTopHeader?: boolean; withTopHeader?: boolean;
withVariants?: 'generic' | 'translations'; variants: {
generic: SiteSpace[];
translations: SiteSpace[];
};
}) { }) {
const { context, withTopHeader, withVariants } = props; const { context, withTopHeader, variants } = props;
const { siteSpace, siteSpaces, sections, customization } = context; const { siteSpace, siteSpaces, sections, customization } = context;
const withSections = Boolean( const withSections = Boolean(
@@ -63,7 +67,7 @@ export function Header(props: {
'theme-bold:shadow-tint-12/2' 'theme-bold:shadow-tint-12/2'
)} )}
> >
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div <div
className={tcls( className={tcls(
'gap-4', 'gap-4',
@@ -75,13 +79,19 @@ export function Header(props: {
'py-3', 'py-3',
'min-h-16', 'min-h-16',
'sm:h-16', 'sm:h-16',
CONTAINER_STYLE CONTAINER_STYLE,
'transition-[max-width] duration-300',
'@container/header'
)} )}
> >
<div <div
className={tcls( className={tcls(
'flex max-w-full lg:basis-72', 'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4' 'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? 'lg:@2xl:basis-72'
: null
)} )}
> >
<HeaderMobileMenu <HeaderMobileMenu
@@ -91,8 +101,8 @@ export function Header(props: {
'theme-bold:text-header-link', 'theme-bold:text-header-link',
'hover:bg-tint-hover', 'hover:bg-tint-hover',
'hover:theme-bold:bg-header-link/3', 'hover:theme-bold:bg-header-link/3',
withVariants === 'generic' variants.generic.length > 1
? 'xl:hidden' ? 'lg:hidden'
: 'page-no-toc:hidden lg:hidden' : 'page-no-toc:hidden lg:hidden'
)} )}
/> />
@@ -104,36 +114,37 @@ export function Header(props: {
'flex', 'flex',
'grow-0', 'grow-0',
'shrink-0', 'shrink-0',
'md:basis-56', '@2xl:basis-56',
'justify-self-end', 'justify-self-end',
'items-center', 'items-center',
'gap-2', 'gap-2',
'transition-[margin] duration-300',
'search' in customization.styling && 'search' in customization.styling &&
customization.styling.search === 'prominent' customization.styling.search === 'prominent'
? [ ? [
'md:grow-[0.8]', '@2xl:grow-[0.8]',
'lg:basis-40', '@4xl:basis-40',
'md:max-w-[40%]', '@2xl:max-w-[40%]',
'lg:max-w-lg', '@4xl:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) 'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem) 'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'md:mr-auto', '@2xl:mr-auto',
'order-last', 'order-last',
'md:order-[unset]', '@2xl:order-[unset]',
] ]
: ['order-last'] : ['order-last']
)} )}
> >
<SearchContainer <SearchContainer
style={customization.styling.search} style={customization.styling.search}
withVariants={withVariants === 'generic'} withVariants={variants.generic.length > 1}
withSiteVariants={ withSiteVariants={
sections?.list.some( sections?.list.some(
(s) => (s) =>
s.object === 'site-section' && s.siteSpaces.length > 1 s.object === 'site-section' && s.siteSpaces.length > 1
) ?? false ) ?? false
} }
withSections={!!sections} withSections={sections ? sections.list.length > 1 : false}
section={ section={
sections sections
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear ? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
@@ -150,7 +161,7 @@ export function Header(props: {
</div> </div>
{customization.header.links.length > 0 || {customization.header.links.length > 0 ||
(!withSections && withVariants === 'translations') ? ( (!withSections && variants.translations.length > 1) ? (
<HeaderLinks> <HeaderLinks>
{customization.header.links.length > 0 ? ( {customization.header.links.length > 0 ? (
<> <>
@@ -170,11 +181,15 @@ export function Header(props: {
/> />
</> </>
) : null} ) : null}
{!withSections && withVariants === 'translations' ? ( {!withSections && variants.translations.length > 1 ? (
<TranslationsDropdown <TranslationsDropdown
context={context} context={context}
siteSpace={siteSpace} siteSpace={
siteSpaces={siteSpaces} variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3" className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
/> />
) : null} ) : null}
@@ -187,14 +202,17 @@ export function Header(props: {
{sections && withSections ? ( {sections && withSections ? (
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}> <SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
{withVariants === 'translations' ? ( {variants.translations.length > 1 ? (
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l"> <TranslationsDropdown
<TranslationsDropdown context={context}
context={context} siteSpace={
siteSpace={siteSpace} variants.translations.find(
siteSpaces={siteSpaces} (space) => space.id === siteSpace.id
/> ) ?? siteSpace
</div> }
siteSpaces={variants.translations}
className="my-2 ml-2 self-start"
/>
) : null} ) : null}
</SiteSectionTabs> </SiteSectionTabs>
</div> </div>
@@ -14,7 +14,7 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
<div <div
className={tcls( className={tcls(
styles.containerHeaderlinks, styles.containerHeaderlinks,
'lg:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow items-center justify-end gap-x-4 lg:gap-x-6 xl:grow-0', '@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
style style
)} )}
> >
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { Image } from '@/components/utils'; import { Image } from '@/components/utils';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { resolveContentRef } from '@/lib/references';
import { Link } from '../primitives'; import { Link } from '../primitives';
import { CurrentContentIcon } from './CurrentContentIcon'; import { CurrentContentIcon } from './CurrentContentIcon';
@@ -18,9 +19,13 @@ export async function HeaderLogo(props: HeaderLogoProps) {
const { context } = props; const { context } = props;
const { customization, linker } = context; const { customization, linker } = context;
const primaryLink = customization.header.primaryLink
? await resolveContentRef(customization.header.primaryLink, context)
: null;
return ( return (
<Link <Link
href={linker.toPathInSite('')} href={primaryLink?.href ?? linker.toPathInSite('')}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')} className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
> >
{customization.header.logo ? ( {customization.header.logo ? (
@@ -46,7 +51,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
width: 260, width: 260,
}, },
]} ]}
priority="high" preload
style={tcls( style={tcls(
'overflow-hidden', 'overflow-hidden',
'shrink', 'shrink',
@@ -45,11 +45,12 @@ export function SpacesDropdown(props: {
} }
> >
<SpacesDropdownMenuItems <SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((space) => ({ slimSpaces={siteSpaces.map((siteSp) => ({
id: space.id, id: siteSp.id,
title: space.title, title: siteSp.title,
url: getSiteSpaceURL(context, space), url: getSiteSpaceURL(context, siteSp),
isActive: space.id === siteSpace.id, isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}))} }))}
curPath={siteSpace.path} curPath={siteSpace.path}
/> />
@@ -1,26 +1,39 @@
'use client'; 'use client';
import type { Space } from '@gitbook/api';
import { joinPath } from '@/lib/paths'; import { joinPath } from '@/lib/paths';
import { useCurrentPagePath } from '../hooks'; import { useCurrentPageMetadata, useCurrentPagePath } from '../hooks';
import { DropdownMenuItem } from '../primitives/DropdownMenu'; import { DropdownMenuItem } from '../primitives/DropdownMenu';
interface VariantSpace { interface VariantSpace {
id: Space['id']; id: string;
title: Space['title']; title: string;
url: string; url: string;
isActive: boolean; isActive: boolean;
spaceId: string;
} }
// When switching to a different variant space, we reconstruct the URL by swapping the space path. /**
function useVariantSpaceHref(variantSpaceUrl: string, currentSpacePath: string, active = false) { * Return the href for a variant space, taking into account the current page path and metadata.
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath(); const currentPathname = useCurrentPagePath();
const { metaLinks } = useCurrentPageMetadata();
// We first check if there is an alternate link for the variant space in the current page metadata.
const pageHasAlternateForVariant = metaLinks?.alternates.find(
(alt) => alt.space?.id === variantSpace.spaceId
);
if (pageHasAlternateForVariant) {
return pageHasAlternateForVariant.href;
}
// If there is no alternate link, we reconstruct the URL by swapping the space path.
// We need to ensure that the variant space URL is not the same as the current space path. // We need to ensure that the variant space URL is not the same as the current space path.
// If it is, we return only the variant space URL to redirect to the root of the variant space. // If it is, we return only the variant space URL to redirect to the root of the variant space.
// This is necessary in case the currentPathname is the same as the variantSpaceUrl, // This is necessary in case the currentPathname is the same as the variantSpaceUrl,
// otherwise we would redirect to the same space if the variant space that we are switching to is the default one. // otherwise we would redirect to the same space if the variant space that we are switching to is the default one.
const variantSpaceUrl = variantSpace.url;
if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) { if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) {
return variantSpaceUrl; return variantSpaceUrl;
} }
@@ -44,7 +57,7 @@ export function SpacesDropdownMenuItem(props: {
currentSpacePath: string; currentSpacePath: string;
}) { }) {
const { variantSpace, active, currentSpacePath } = props; const { variantSpace, active, currentSpacePath } = props;
const variantHref = useVariantSpaceHref(variantSpace.url, currentSpacePath, active); const variantHref = useVariantSpaceHref(variantSpace, currentSpacePath, active);
return ( return (
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}> <DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>
@@ -1,4 +1,4 @@
@media (max-width: 1279px) { @container header (width < 1280px) {
.containerHeaderlinks { .containerHeaderlinks {
container-type: inline-size; container-type: inline-size;
container-name: headerlinks; container-name: headerlinks;
@@ -127,9 +127,16 @@ export function InsightsProvider(props: InsightsProviderProps) {
} }
}); });
const flushBatchedEvents = useDebounceCallback(() => { const flushBatchedEvents = useDebounceCallback(
flushEventsSync(); () => {
}, 1500); flushEventsSync();
},
1500,
{
// We don't want to debounce indefinitely, after 1 minute we want to flush anyway
maxWait: 60000,
}
);
// Flush pending events once the visitor session has been fetched // Flush pending events once the visitor session has been fetched
React.useEffect(() => { React.useEffect(() => {

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