Compare commits

...

118 Commits

Author SHA1 Message Date
Samy Pessé 75b52eac28 Version Packages (#3651)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-10 15:11:33 +02:00
Greg Bergé ad4d07dc06 Support inline icon colors (#3717) 2025-10-10 14:51:40 +02:00
Greg Bergé 75086740f8 Fix bundling of embed package (#3716) 2025-10-10 10:57:06 +02:00
Nolann B. eea8f1e00f Enhance OpenAPI security scopes handling (#3712) 2025-10-09 10:16:12 +02:00
Greg Bergé 4f9abfb6f2 Display headings from reusable content in TOC (#3708) 2025-10-08 12:17:05 +02:00
conico974 252afceab0 Store customization in a cookie for preview route (#3707)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-10-08 10:37:52 +02:00
Viktor Renkema bef191660a Allow hiding toolbar for one session or persistent (#3698) 2025-10-08 09:48:38 +02:00
Zeno Kapitein 145625142d Unify section display condition (#3706) 2025-10-07 15:12:08 +02:00
Greg Bergé f70a2aa310 Disable flashing when navigating between sections (#3705) 2025-10-07 12:00:42 +02:00
Samy Pessé 56e46ce8ab Add Italian translation (#3704) 2025-10-06 17:07:30 +02:00
Nolann B. 754cc11e0b Fix merge logic for allOf alternatives (#3515) 2025-10-06 14:37:51 +02:00
Nolann B. 3551d147e9 Move to urls for page actions (#3703) 2025-10-06 09:42:37 +02:00
Zeno Kapitein 162cfe4163 Hide sections if only one is present (#3701) 2025-10-04 20:16:02 +02:00
Nolann B. 6815dd197e Fix markdownPageURL for PageActions (#3702) 2025-10-04 19:02:30 +02:00
Zeno Kapitein d74f8a7098 Improve wide page width (#3692) 2025-10-03 18:52:05 +02:00
Zeno Kapitein 1e53f46521 Fix button content truncate (#3693) 2025-10-03 18:51:32 +02:00
Claire Chabas 1a6216df4f Fix URL used for Ask to LLM (#3697) 2025-10-03 14:24:24 +02:00
Nolann B. a629900324 Add dev script for @gitbook/expr (#3700) 2025-10-03 13:23:15 +02:00
Nolann B. a4c33997fe Fix OpenAPI alternatives not showing (#3699) 2025-10-03 13:06:10 +02:00
Brett Jephson badf3a92f6 RND-8165: make sure we get alt text coming through on InlineImage (#3696) 2025-10-03 09:40:34 +01:00
Claire Chabas e0fb9ac3ca Support image frame (#3695) 2025-10-02 10:17:48 +02:00
spastorelli f3e40410c4 Adapt OpenAPI code samples to prefill API key using visitor data (#3666) 2025-09-30 11:09:05 +02:00
spastorelli 8e99871004 Highlight discriminator properties in oneOf, allOf, anyOf objects (#3691) 2025-09-29 17:09:21 +02:00
Zeno Kapitein 1e4e54e752 Fix rounded outline items (#3694) 2025-09-29 13:33:40 +02:00
spastorelli 50c1be3447 Move inline expression evaluation from API to GBO (#3687) 2025-09-26 10:55:09 +02:00
spastorelli 319a1e536c Ensure operation security reqs inherits from spec-level when not defined (#3690) 2025-09-26 09:11:52 +02:00
conico974 50cb0c6e15 Fix NavigationLoader on search and cmd/click (#3689)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-26 08:34:41 +02:00
Zeno Kapitein ceb32b1a2e Redo search scopes (#3688)
Co-authored-by: Claire Chabas <claire.chabas@gmail.com>
2025-09-25 16:49:53 +00:00
Zeno Kapitein f8c54b4d34 Improve assistant demo (#3686) 2025-09-25 17:07:07 +02:00
Greg Bergé c06ad70f8a Improve fonts loading (#3682) 2025-09-25 13:17:21 +02:00
Brett Jephson 0ef5dc8538 Update column width sizing (#3676) 2025-09-25 08:14:15 +01:00
Zeno Kapitein 28adc9b5f1 Add graphic to embed assistant demo page (#3685) 2025-09-24 14:37:41 +00:00
Zeno Kapitein 912d1693f5 Fix section group alignment (#3684) 2025-09-24 13:58:34 +00:00
Samy Pessé c23d4ef331 Fix crash for card cover defined without objectFit (#3683) 2025-09-24 11:01:06 +02:00
Zeno Kapitein c21693fe50 Improve screen reader accessibility for hints (#3680) 2025-09-24 10:23:34 +02:00
Zeno Kapitein c06b3dd60d Improve default site icon (#3679) 2025-09-24 10:22:49 +02:00
conico974 f173e014db Use cloudflare regional cache instead of our own implementation. (#3673)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 10:11:11 +02:00
conico974 1b458c436e Fix NavigationLoader not visible with bold header (#3681)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 09:54:57 +02:00
conico974 b42b0b3910 Add navigation loader and rename context provider (#3642)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-09-23 15:00:27 +02:00
Brett Jephson 2fc21272ff RND-7985: accessible search (#3637) 2025-09-23 11:36:50 +01:00
Zeno Kapitein 379d486488 Expose "Best match" when site has translations (#3670) 2025-09-22 22:31:17 +02:00
Zeno Kapitein cba583a31c Use siteSpace title instead of space title in search (#3677) 2025-09-22 18:03:50 +02:00
Zeno Kapitein 7a504b7cc5 Fix OpenAPI response dropdown & vertical overflow (#3678) 2025-09-22 18:03:43 +02:00
Addison 8ec5fd0986 Add blob: to style source for integrations (#3675) 2025-09-22 18:41:16 +09:00
spastorelli 529f940a45 Fix OpenAPISecurities and code sample not using operation security requirements (#3671) 2025-09-22 10:00:46 +02:00
Viktor Renkema 8a3c15911f Update tooltip labels in docs sites toolbars (#3674) 2025-09-22 09:28:42 +02:00
spastorelli d7948e34b2 Fix OpenAPI response showing as JSON instead of YAML (#3669) 2025-09-18 18:40:24 +02:00
conico974 9f42211993 Enhance tracking with geolocation headers and continent info in analytics proxy (#3667)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:31:21 +02:00
conico974 65e62307bf Update @opennextjs/cloudflare to version 1.8.3 (#3668)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:04:38 +02:00
spastorelli 469b332da2 Add support for inline expressions rendering with visitor data on GBO side (#3660) 2025-09-18 16:30:52 +02:00
Zeno Kapitein e434442f1e Support site section groups (#3664) 2025-09-18 15:28:39 +02:00
Samy Pessé 9344431bfc Fix API URL we proxy insights events to (#3665) 2025-09-18 13:14:25 +00:00
conico974 622ecbec1d Handle broken links in header and footer (#3663)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 11:22:57 +02:00
conico974 fa1382b289 Add responsive image sizes for RecordCard component (#3654)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 08:55:11 +02:00
Zeno Kapitein b8388e5a2a Add ScrollContainer component, use for section tabs (#3661) 2025-09-17 22:20:32 +02:00
Zeno Kapitein 2bce12798f Fix section group dropdown offset on big screens (#3662) 2025-09-17 19:10:52 +02:00
Greg Bergé f89e889d18 Convert relative URL to absolute in Markdown (page and copy) (#3659) 2025-09-17 18:12:01 +02:00
Zeno Kapitein 2b8a2d24b6 Add stable scroll gutter to search container (#3656) 2025-09-17 17:27:47 +02:00
Zeno Kapitein b85eccdff5 Refactor site section tabs (#3655) 2025-09-17 17:27:34 +02:00
Viktor Renkema d924e655c0 Add visibility label and smoothen magnification (#3657) 2025-09-16 16:34:21 +02:00
Zeno Kapitein b932e4e2e9 Scope search across sections and variants (#3640)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 12:07:29 +02:00
Samy Pessé ca4a3e8c9a Track utm on links from the toolbar (#3653) 2025-09-15 16:51:31 +02:00
John Cashman b498521196 Refactor robots.txt generation logic (#3621)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 14:37:02 +02:00
Samy Pessé 8a8cf2025d Skip using __session cookie if it's in the old format (#3652) 2025-09-15 12:10:51 +02:00
Samy Pessé ed7d47d9b8 Toolbar for authenticated GitBook users (#3650)
Co-authored-by: Viktor Renkema <vrenkema@gmail.com>
2025-09-15 10:32:23 +02:00
Viktor Renkema b5494cc04b Restyle the toolbar in CR and revision preview links (#3631)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 08:33:23 +02:00
Samy Pessé 229f2ba9d5 Version Packages (#3646)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 16:40:28 +02:00
spastorelli 6da36558f6 Fix exports in gitbook/expr package.json after removing src (#3648) 2025-09-12 16:20:29 +02:00
Samy Pessé ca71c1fad6 Fix MCP in production (#3647) 2025-09-12 14:59:47 +02:00
Samy Pessé 262afa357b Implement an MCP server on each docs site (#3641) 2025-09-12 13:19:16 +02:00
Samy Pessé 7375d3c597 Version Packages (#3645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 11:37:22 +02:00
spastorelli 3548fa6dff Fix eval estree expr cjs named import when gitbook/expr is imported in playwright tests (#3643) 2025-09-12 11:20:33 +02:00
conico974 cb73040e0f Bump Next.js to version 15.4.0 (#3644)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-12 11:17:43 +02:00
Samy Pessé 872d36b64f Version Packages (#3629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-11 18:06:31 +02:00
spastorelli e1ff17e655 Fix bundling of @gitbook/expr package (#3639) 2025-09-11 17:24:06 +02:00
Utku Ufuk f3010bd28a Stop using deprecated search API param (#3638) 2025-09-11 15:26:35 +03:00
spastorelli 8ff1e3b619 Port latest changes from gbx util-expr (#3636) 2025-09-09 18:55:41 +02:00
Viktor Renkema d7596bf454 Don't show toolbar when rendering within GitBook app preview (#3635) 2025-09-09 15:09:52 +02:00
Zeno Kapitein aea5eb10ae Persist language choice across sections (#3633) 2025-09-09 15:06:28 +02:00
Zeno Kapitein 1165a81cf5 Language selector fixes (#3632) 2025-09-08 17:22:42 +02:00
conico974 61d1a0192e Fix null contentRef handling in RecordColumnValue (#3566)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-08 13:54:58 +02:00
Zeno Kapitein f9a2977621 Better handling for external link "mailto:" in Hovered Card in GBO (#3630) 2025-09-08 10:31:30 +00:00
spastorelli 24f601d594 Small optim in resolveTryItPrefillForOperation and remove uneeded dep (#3628) 2025-09-08 10:29:59 +02:00
Samy Pessé 7b34537bbe Version Packages (#3599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-06 11:40:05 +02:00
conico974 145f385fdb Add pagination for llms-full.txt file (#3619)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 17:20:25 +02:00
Greg Bergé 360aa1c6d6 Upgrade Scalar dependencies (#3626) 2025-09-05 16:57:21 +02:00
conico974 ccc0975240 Fix error on parameters being an object instead of an array (#3623)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 16:17:50 +02:00
Zeno Kapitein 791135040a Add language selector to site header (#3622) 2025-09-05 12:15:07 +00:00
spastorelli 12c9d765ff Adapt OpenAPI to eval adaptive exprs & prefill TryIt config (#3607) 2025-09-05 13:39:12 +02:00
Greg Bergé 45207288a1 Support bold in headings (#3609) 2025-09-05 11:47:39 +02:00
Viktor Renkema 177afa5828 Handle non-defined objectFit on card cover images (#3625) 2025-09-05 10:42:57 +02:00
Zeno Kapitein d51b79edbb Fix "Search bar is broken on site with sections when header is disabled" (#3624) 2025-09-04 13:48:08 +02:00
conico974 a2720ac49d Update @opennextjs/cloudflare to version 1.7.1 (#3620)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-03 16:09:27 +02:00
Viktor Renkema 5f6853d812 Support new object-fit property on individual card cover image (#3594) 2025-09-03 09:11:17 +02:00
Zeno Kapitein 17dd382dc3 Add "original" color step and apply to bg-primary-solid elements (#3618) 2025-09-02 16:08:21 +02:00
Nolann B. 98e42cfe79 Handle OpenAPI ignored responses (#3614) 2025-09-01 14:25:35 +02:00
Samy Pessé c890e01004 Fix order in robots.txt preventing indexation of images by Google (#3617) 2025-09-01 11:21:11 +00:00
Samy Pessé 659be551bb Track insight event when embedded assistant is displayed (#3616) 2025-09-01 12:59:40 +02:00
Zeno Kapitein 78a632b7fe Fix Edit on GitHub button doesn't take you to GitHub (#3615) 2025-09-01 12:01:36 +02:00
spastorelli 4927e964b7 Add support for YAML content type in request body/example (#3612) 2025-09-01 10:56:09 +02:00
Samy Pessé 262a9b198b Fix embed script caching (#3611) 2025-08-29 22:35:14 +02:00
Samy Pessé 4f3588240c Unify layout and fix tracking of ask_question (#3610) 2025-08-29 16:10:28 +02:00
Nolann B. abbae3ec4d Remove scroll-nojump utility and update layout structure (#3494)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-29 11:22:55 +02:00
conico974 1b8609ac60 fix next start not working (#3608)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-28 16:36:06 +02:00
Samy Pessé 193d591e9f Use space.language as source of truth (#3606) 2025-08-27 18:27:29 +02:00
Zeno Kapitein 1edc5d622a Assistant hold message (#3603) 2025-08-27 13:48:57 +00:00
Zeno Kapitein 61b8507996 OpenAPI: Make empty responses clickable (#3605) 2025-08-27 13:38:24 +00:00
Zeno Kapitein 6f368b5cf3 Fix embed assistant window width on small screens (#3604) 2025-08-27 15:33:37 +02:00
Brett Jephson 9201e2cf52 Add vertical align property to column block (#3596)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-08-27 13:58:26 +01:00
Zeno Kapitein 2e0d706d43 Fix corner radius of active section on "Line" sidebar style (#3601) 2025-08-27 13:15:07 +02:00
Nolann B. 7fefe4919c Catch OpenAPI validation errors (#3602) 2025-08-27 12:29:50 +02:00
Zeno Kapitein 729921f338 Move "Edit on Git" and "Export PDF" actions into page actions dropdown (#3574) 2025-08-27 11:32:55 +02:00
Zeno Kapitein 1839ea2678 Fix content min-height with sections (#3597) 2025-08-27 11:30:22 +02:00
Zeno Kapitein f08dd29364 Fix Search results are not clickable (#3595) 2025-08-26 20:33:37 +00:00
conico974 ca2dfa3065 Rollback to Next.js 15.3 (#3600)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-26 18:26:32 +02:00
Utku Ufuk 10ded437f0 Turn on the "noUncheckedIndexedAccess" flag in all TS config files (#3587) 2025-08-26 19:25:54 +03:00
Zeno Kapitein d1fdc13441 Remove ligatures from Lato font (#3592) 2025-08-26 15:04:22 +00:00
Zeno Kapitein a8fca0e033 Fix custom assistant keyboard shortcut (#3598) 2025-08-26 14:57:08 +00:00
271 changed files with 10889 additions and 3833 deletions
+23 -5
View File
@@ -182,9 +182,9 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/ SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2' ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v2: browserless-testing-v2-vercel:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: PageSpeed Testing v1 name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel needs: deploy-v2-vercel
steps: steps:
- name: Checkout - name: Checkout
@@ -195,8 +195,26 @@ jobs:
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests - name: Run tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts run: cd ./packages/gitbook && bun e2e-browserless
env: env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}} BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }} SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
# needs: deploy-v2-cloudflare
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# - name: Setup Bun
# uses: ./.github/composite/setup-bun
# - name: Install dependencies
# run: bun install --frozen-lockfile
# env:
# PUPPETEER_SKIP_DOWNLOAD: 1
# - name: Run tests
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
+604 -418
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12", "@changesets/cli": "^2.29.7",
"turbo": "^2.5.0", "turbo": "^2.5.8",
"vercel": "^39.3.0" "vercel": "^39.4.2"
}, },
"packageManager": "bun@1.2.15", "packageManager": "bun@1.2.15",
"overrides": { "overrides": {
@@ -16,7 +16,7 @@
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev --concurrency 20",
"build": "turbo run build", "build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules", "clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
@@ -26,16 +26,18 @@
"e2e": "turbo run e2e", "e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers", "e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format", "changeset-version": "changeset version && bun run format && bun update",
"release": "turbo run release && changeset publish", "release": "turbo run release && bun run publish-all-packages",
"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.136.0", "@gitbook/api": "0.143.2",
"bidc": "^0.0.2" "bidc": "^0.0.2",
"tsdown": "^0.15.6"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false, "noEmit": false,
"declaration": true, "declaration": true,
"outDir": "dist", "outDir": "dist",
+2 -14
View File
@@ -121,17 +121,6 @@ export function getCacheTag(
} }
} }
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/** /**
* Get the tags for a computed content source. * Get the tags for a computed content source.
*/ */
@@ -182,7 +171,7 @@ export function getComputedContentSourceCacheTags(
break; break;
default: default:
// Do not throw for unknown dependency types // Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version // as it might mean we are lagging behind the API version
break; break;
} }
}); });
@@ -198,9 +187,8 @@ export function getComputedContentSourceCacheTags(
} }
// We invalidate the computed content when a new version of the integration is deployed. // We invalidate the computed content when a new version of the integration is deployed.
if (source.type.startsWith('integration:')) { if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1]; const integration = source.type.split(':')[1]!;
tags.push( tags.push(
getCacheTag({ getCacheTag({
tag: 'integration', tag: 'integration',
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false, "noEmit": false,
"declaration": true, "declaration": true,
"outDir": "dist", "outDir": "dist",
+10
View File
@@ -1,5 +1,15 @@
# @gitbook/colors # @gitbook/colors
## 0.4.0
### Minor Changes
- 17dd382: Add `original` background color step
### Patch Changes
- 193d591: Fix return type for `colorContrast`
## 0.3.3 ## 0.3.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.3", "version": "0.4.0",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
+16 -11
View File
@@ -8,7 +8,7 @@ type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number }; type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number }; type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65) const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
export enum ColorCategory { export enum ColorCategory {
backgrounds = 'backgrounds', backgrounds = 'backgrounds',
@@ -19,7 +19,7 @@ export enum ColorCategory {
} }
type ColorSubScale = { type ColorSubScale = {
[key: string]: number; [key: string]: number | string;
}; };
/** /**
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
solid: 9, solid: 9,
/** Hovered solid backgrounds */ /** Hovered solid backgrounds */
'solid-hover': 10, 'solid-hover': 10,
/** Original color */
original: 'original',
}, },
[ColorCategory.text]: { [ColorCategory.text]: {
/** Very low-contrast text /** Very low-contrast text
@@ -211,8 +213,8 @@ export function colorScale(
const result = []; const result = [];
for (let index = 0; index < mapping.length; index++) { for (let index = 0; index < mapping.length; index++) {
const targetL = const step = mapping[index]!;
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
if ( if (
index === 8 && index === 8 &&
@@ -295,7 +297,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
export function getColor(percentage: number, start: RGBColor, end: RGBColor) { export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => { const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel)); return Math.round(channel + percentage * (start[index]! - channel));
}); });
return rgbArrayToHex(rgb as RGBColor); return rgbArrayToHex(rgb as RGBColor);
@@ -392,14 +394,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
B: number; B: number;
} { } {
const [x, y, z] = xyz.map((v, i) => { const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i]; const scaled = v / D65[i]!;
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116; return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
}); });
return { return {
L: 116 * y - 16, L: 116 * y! - 16,
A: 500 * (x - y), A: 500 * (x! - y!),
B: 200 * (y - z), B: 200 * (y! - z!),
}; };
} }
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
return contrast < 7.5 ? 0 : contrast; return contrast < 7.5 ? 0 : contrast;
} }
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) { export function colorContrast(
background: string,
foreground: string[] = [LIGHT_BASE, DARK_BASE]
): string {
const bg = hexToRgbArray(background); const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = { const best: { color?: RGBColor; contrast: number } = {
@@ -436,5 +441,5 @@ export function colorContrast(background: string, foreground: string[] = [LIGHT_
} }
} }
return best.color ? rgbArrayToHex(best.color) : foreground[0]; return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
} }
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false, "noEmit": false,
"declaration": true, "declaration": true,
"outDir": "dist", "outDir": "dist",
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/embed # @gitbook/embed
## 0.1.2
### Patch Changes
- 7508674: Fix bundling of packages
## 0.1.1
### Patch Changes
- 6f368b5: Fix embed assistant window width on small screens
## 0.1.0 ## 0.1.0
### Minor Changes ### Minor Changes
+12 -6
View File
@@ -5,12 +5,14 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js", "default": "./dist/index.js"
"standalone": "./dist/standalone/index.js", },
"react": "./dist/react/index.js" "./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
} }
}, },
"version": "0.1.0", "version": "0.1.2",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:", "@gitbook/icons": "workspace:",
@@ -20,12 +22,16 @@
"react": "^18.0.0" "react": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"tsdown": "catalog:",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"react": "^19.0.0" "react": "^19.0.0"
}, },
"scripts": { "scripts": {
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone", "build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"] "files": ["dist", "README.md", "CHANGELOG.md"]
} }
+2 -1
View File
@@ -2,6 +2,7 @@
--gitbook-widget-top: 1rem; --gitbook-widget-top: 1rem;
--gitbook-widget-bottom: 1rem; --gitbook-widget-bottom: 1rem;
--gitbook-widget-right: 1rem; --gitbook-widget-right: 1rem;
--gitbook-widget-left: 1rem;
--gitbook-widget-button-height: 46px; --gitbook-widget-button-height: 46px;
@@ -117,7 +118,7 @@
bottom: var(--gitbook-widget-window-bottom); bottom: var(--gitbook-widget-window-bottom);
right: var(--gitbook-widget-right); right: var(--gitbook-widget-right);
z-index: 9998; z-index: 9998;
width: var(--gitbook-widget-window-width); width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top)))); height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-solid); background-color: var(--gitbook-widget-background-solid);
border: 1px solid var(--gitbook-widget-border-color); border: 1px solid var(--gitbook-widget-border-color);
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts', 'src/react/index.ts'],
dts: true,
format: ['esm'],
},
]);
+28
View File
@@ -1,5 +1,33 @@
# @gitbook/expr # @gitbook/expr
## 1.2.1
### Patch Changes
- a629900: Add dev script for @gitbook/expr
## 1.2.0
### Minor Changes
- 6da3655: Fix exports in gitbook/expr package.json
## 1.1.1
### Patch Changes
- 3548fa6: Fix eval-estree-expr named import
## 1.1.0
### Minor Changes
- e1ff17e: Fix bundling of gitbook/expr package
### Patch Changes
- 8ff1e3b: Add support for every/some array methods
## 1.0.0 ## 1.0.0
### Major Changes ### Major Changes
+11 -7
View File
@@ -1,34 +1,38 @@
{ {
"name": "@gitbook/expr", "name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.", "description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.0.0", "version": "1.2.1",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts", "import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"eval-estree-expression": "^2.0.3", "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",
"escodegen": "^2.1.0",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.20", "bun-types": "^1.1.20",
"tsdown": "catalog:",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@babel/types": "^7.26.0", "@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10"
}, },
"scripts": { "scripts": {
"build": "tsc --project tsconfig.build.json", "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"
}, },
"files": ["dist", "src", "README.md", "CHANGELOG.md"] "files": ["dist", "README.md", "CHANGELOG.md"]
} }
@@ -210,6 +210,8 @@ describe('autocomplete', () => {
'visitor.claims.hello.length', 'visitor.claims.hello.length',
'visitor.claims.hello.at', 'visitor.claims.hello.at',
'visitor.claims.hello.includes', 'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
], ],
}, },
}, },
@@ -268,6 +270,8 @@ describe('autocomplete', () => {
'visitor.claims.hello.length', 'visitor.claims.hello.length',
'visitor.claims.hello.at', 'visitor.claims.hello.at',
'visitor.claims.hello.includes', 'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
], ],
}, },
}, },
+20 -2
View File
@@ -42,11 +42,29 @@ describe('ExpressionRuntime', () => {
}, },
expectedResult: true, expectedResult: true,
}, },
{
scenario: 'array method',
condition: 'reviews.every(review => !!review.status)',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array every',
condition: 'reviews.every(review => review.status === "approved")',
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
])( ])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario', 'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => { ({ condition, inputs, expectedResult }) => {
expect(runtime.evaluate(condition, inputs)).toBe(expectedResult); expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult); expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
} }
); );
-1
View File
@@ -1,6 +1,5 @@
export * from './errors'; export * from './errors';
export * from './input-values'; export * from './input-values';
export * from './input-values';
export * from './runtime'; export * from './runtime';
export * from './symbols'; export * from './symbols';
export * from './template'; export * from './template';
+4 -1
View File
@@ -9,7 +9,9 @@ import {
tokenizer, tokenizer,
} from 'acorn'; } from 'acorn';
import { parse as parseLoose } from 'acorn-loose'; import { parse as parseLoose } from 'acorn-loose';
import { evaluate } from 'eval-estree-expression'; import escodegen from 'escodegen';
import evalESTreeExpr from 'eval-estree-expression';
const { evaluate } = evalESTreeExpr;
import { AutoComplete } from './autocomplete'; import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors'; import { ExpressionError } from './errors';
@@ -49,6 +51,7 @@ export class ExpressionRuntime {
return evaluate.sync<Expression>(parsed.result, inputs, { return evaluate.sync<Expression>(parsed.result, inputs, {
functions: true, functions: true,
withMembers: true, withMembers: true,
generate: escodegen.generate,
}); });
} catch (error) { } catch (error) {
throw error instanceof Error throw error instanceof Error
@@ -338,6 +338,8 @@ describe('ExpressionRuntime', () => {
'visitor.claims.hello.length', 'visitor.claims.hello.length',
'visitor.claims.hello.at', 'visitor.claims.hello.at',
'visitor.claims.hello.includes', 'visitor.claims.hello.includes',
'visitor.claims.hello.some',
'visitor.claims.hello.every',
], ],
}); });
}); });
+54
View File
@@ -259,6 +259,60 @@ const StandardLibrary: Partial<
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).', 'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
}), }),
}), }),
SymbolFunction({
name: 'some',
description:
'Tests whether at least one element in the array passes the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for at least one element in the array.',
}),
}),
SymbolFunction({
name: 'every',
description:
'Tests whether all elements in the array pass the test implemented by the provided function.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
args: [
SymbolFunction({
name: 'callback',
description: 'A function that tests each element of the array.',
args: [
{
...arraySymbolDef.items,
name: 'element',
description: 'The current element being processed in the array.',
},
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
],
returns: SymbolBoolean({
description:
'true if the callback function returns a truthy value for all elements in the array.',
}),
}),
], ],
}), }),
}; };
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["./tsconfig.json"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts'],
dts: true,
format: ['esm'],
},
]);
+1 -1
View File
@@ -14,7 +14,7 @@ declare module 'eval-estree-expression' {
/** /**
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined * Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
*/ */
generate?: boolean; generate?: boolean | ((node: any) => string);
/** /**
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true * Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
*/ */
+2 -2
View File
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
const body = token.slice(2); // drop "U+" const body = token.slice(2); // drop "U+"
const [startHex, endHex] = body.split('-'); const [startHex, endHex] = body.split('-');
const start = Number.parseInt(startHex, 16); const start = Number.parseInt(startHex!, 16);
const end = endHex ? Number.parseInt(endHex, 16) : start; const end = endHex ? Number.parseInt(endHex, 16) : start;
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null; if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
@@ -92,7 +92,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
for (const [label, rangesArr] of Object.entries(parsed)) { for (const [label, rangesArr] of Object.entries(parsed)) {
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) { if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
hits[label]++; hits[label]!++;
} }
} }
} }
+1
View File
@@ -4,6 +4,7 @@
"module": "ESNext", "module": "ESNext",
"target": "es2022", "target": "es2022",
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
+119
View File
@@ -1,5 +1,124 @@
# gitbook # gitbook
## 0.19.0
### Minor Changes
- ed7d47d: Show a toolbar for authenticated users with access to the current site.
- 50c1be3: Move inline expression evaluation from API to GBO
- 56e46ce: Add Italian translation
- b932e4e: Scope search across sections and variants
### Patch Changes
- 162cfe4: Hide sections if only one is present
- 1456251: Unify section display condition
- 9344431: Fix missing geo data in site insights because of incorrect proxying of events.
- eea8f1e: Enhance OpenAPI security scopes handling
- 2b8a2d2: Add stable scroll gutter to search container
- b498521: Fix robots.txt preventing favicon from being indexed.
- ceb32b1: Redo search scope UI
- 8e99871: Highlight discriminator properties in oneOf, allOf, anyOf objects
- 2fc2127: Make search accessible
- f3e4041: Adapt OpenAPI code samples to prefill API key using visitor data
- 6815dd1: Fix markdownPageURL for PageActions
- c06b3dd: Improve default site icon
- cba583a: Use sitespace title instead of space title in search
- e434442: Support nested site section groups
- 469b332: Add support for inline expressions rendering with visitor data on GBO side
- 0ef5dc8: Update to column width sizing
- 1e4e54e: Fix rounded outline items
- 379d486: Expose "Best match" when site has translations
- c21693f: Improve screen reader accessibility for hints
- badf3a9: Add alt text to inline image
- c23d4ef: Fix crash for card cover defined without objectFit
- d74f8a7: Improve wide page width layout
- b8388e5: Add scrollcontainer component
- b85eccd: Refactor section tabs
- 1e53f46: Fix button content truncate
- 7a504b7: Fix OpenAPI response dropdown & vertical overflow
- a4c3399: Fix OpenAPI alternatives not showing
- Updated dependencies [a629900]
- Updated dependencies [eea8f1e]
- Updated dependencies [8e99871]
- Updated dependencies [f3e4041]
- Updated dependencies [319a1e5]
- Updated dependencies [529f940]
- Updated dependencies [754cc11]
- Updated dependencies [d7948e3]
- Updated dependencies [a4c3399]
- @gitbook/expr@1.2.1
- @gitbook/react-openapi@1.5.0
## 0.18.0
### Minor Changes
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
### Patch Changes
- Updated dependencies [262afa3]
- @gitbook/icons@0.3.1
- @gitbook/react-openapi@1.4.3
- @gitbook/react-contentkit@0.7.5
## 0.17.2
### Patch Changes
- @gitbook/react-openapi@1.4.2
## 0.17.1
### Patch Changes
- 24f601d: Small optim in resolveTryItPrefillForOperation
- aea5eb1: Persist language choice across sections if possible
- 1165a81: Language selector edge cases
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
- Updated dependencies [24f601d]
- @gitbook/react-openapi@1.4.1
## 0.17.0
### Minor Changes
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
- 7911350: Add language selector to site header
- 659be55: Track insight event when embedded assistant is displayed.
### Patch Changes
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
- 1edc5d6: Add "hold message" to Assistant
- 4520728: Support bold in headings
- 61b8507: OpenAPI: Make responses without objects clickable
- d1fdc13: Remove ligatures from Lato font
- a8fca0e: Fix custom assistant keyboard shortcut
- 262a9b1: Fix embed script and assets being cached for too long
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
- 1839ea2: Fix content min-height with sections
- 9201e2c: Adds vertical align to column block
- 193d591: Use space language as source of truth for UI locale
- f08dd29: Fix Search results are not clickable on sites without header
- 17dd382: Add `original` background color step
- 4f35882: Fix event ask_question not being tracked
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
- 6f368b5: Fix embed assistant window width on small screens
- Updated dependencies [193d591]
- Updated dependencies [12c9d76]
- Updated dependencies [4927e96]
- Updated dependencies [61b8507]
- Updated dependencies [7fefe49]
- Updated dependencies [360aa1c]
- Updated dependencies [98e42cf]
- Updated dependencies [17dd382]
- @gitbook/colors@0.4.0
- @gitbook/react-openapi@1.4.0
- @gitbook/openapi-parser@3.0.2
## 0.16.0 ## 0.16.0
### Minor Changes ### Minor Changes
+5 -5
View File
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.gmgn.ai', contentBaseURL: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{ // {
name: 'docs.spicychat.ai', // name: 'docs.spicychat.ai',
contentBaseURL: 'https://docs.spicychat.ai', // contentBaseURL: 'https://docs.spicychat.ai',
tests: [{ name: 'Home', url: '/' }], // tests: [{ name: 'Home', url: '/' }],
}, // },
{ {
name: 'docs.portainer.io', name: 'docs.portainer.io',
contentBaseURL: 'https://docs.portainer.io', contentBaseURL: 'https://docs.portainer.io',
+1
View File
@@ -336,6 +336,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
pageActions: { pageActions: {
externalAI: true, externalAI: true,
markdown: true, markdown: true,
mcp: true,
}, },
trademark: { trademark: {
enabled: true, enabled: true,
@@ -17,7 +17,8 @@
"dev": { "dev": {
"vars": { "vars": {
"STAGE": "dev", "STAGE": "dev",
"OPEN_NEXT_REQUEST_ID_HEADER": "true" "OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771"
}, },
"r2_buckets": [ "r2_buckets": [
{ {
@@ -26,8 +26,10 @@ export default class extends WorkerEntrypoint {
async fetch(request) { async fetch(request) {
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
const startTime = Date.now(); const startTime = Date.now();
const middlewareRequest = new Request(request.url, request);
middlewareRequest.headers.set('x-open-next-continent', request.cf?.continent || '');
// - `Request`s are handled by the Next server // - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, this.env, this.ctx); const reqOrResp = await middlewareHandler(middlewareRequest, this.env, this.ctx);
if (reqOrResp instanceof Response) { if (reqOrResp instanceof Response) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const logMessage = formatLog( const logMessage = formatLog(
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
const versionId = args[0]; const versionId = args[0];
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev // The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`; const previewHostname = `${versionId?.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
let updatedFile = file.replace( let updatedFile = file.replace(
/"PREVIEW_HOSTNAME": "TO_REPLACE"/, /"PREVIEW_HOSTNAME": "TO_REPLACE"/,
+19 -87
View File
@@ -7,6 +7,8 @@ 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';
@@ -18,15 +20,12 @@ export type KeyOptions = {
/** /**
* *
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
* the cache operations. It also integrates both R2 and Cache API in a single class. * 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.
* Having our own, will allow us to customize it in the future if needed.
*/ */
class GitbookIncrementalCache implements IncrementalCache { class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache'; name = 'GitbookIncrementalCache';
protected localCache: Cache | undefined;
async get<CacheType extends CacheEntryType = 'cache'>( async get<CacheType extends CacheEntryType = 'cache'>(
key: string, key: string,
cacheType?: CacheType cacheType?: CacheType
@@ -34,7 +33,6 @@ class GitbookIncrementalCache implements IncrementalCache {
const cacheKey = this.getR2Key(key, cacheType); const cacheKey = this.getR2Key(key, cacheType);
const r2 = getCloudflareContext().env[BINDING_NAME]; const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket'); if (!r2) throw new Error('No R2 bucket');
if (process.env.SHOULD_BYPASS_CACHE === 'true') { if (process.env.SHOULD_BYPASS_CACHE === 'true') {
// We are in a local middleware environment, we should bypass the cache // We are in a local middleware environment, we should bypass the cache
@@ -42,27 +40,17 @@ class GitbookIncrementalCache implements IncrementalCache {
return null; return null;
} }
try { try {
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
const result = (await localCacheEntry.json()) as WithLastModified<
CacheValue<CacheType>
>;
return this.returnNullOn404({
...result,
// Because we use tag cache and also invalidate them every time,
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
// this should improve performance even further, and reduce costs
shouldBypassTagCache: true,
});
}
const r2Object = await r2.get(cacheKey); const r2Object = await r2.get(cacheKey);
if (!r2Object) return null; if (!r2Object) return null;
const json = (await r2Object.json()) as CacheValue<CacheType>;
const lastModified = r2Object.uploaded.getTime();
if (!json) return null;
return this.returnNullOn404({ return this.returnNullOn404({
value: await r2Object.json(), value: json,
lastModified: r2Object.uploaded.getTime(), lastModified,
}); });
} catch (e) { } catch (e) {
console.error('Failed to get from cache', e); console.error('Failed to get from cache', e);
@@ -89,39 +77,8 @@ class GitbookIncrementalCache implements IncrementalCache {
): Promise<void> { ): Promise<void> {
const cacheKey = this.getR2Key(key, cacheType); const cacheKey = this.getR2Key(key, cacheType);
const localCache = await this.getCacheInstance();
try { try {
await this.writeToR2(cacheKey, JSON.stringify(value)); await this.writeToR2(cacheKey, JSON.stringify(value));
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];
// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) { } catch (e) {
console.error('Failed to set to cache', e); console.error('Failed to set to cache', e);
} }
@@ -131,14 +88,10 @@ class GitbookIncrementalCache implements IncrementalCache {
const cacheKey = this.getR2Key(key); const cacheKey = this.getR2Key(key);
const r2 = getCloudflareContext().env[BINDING_NAME]; const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket'); if (!r2) throw new Error('No R2 bucket');
try { try {
await r2.delete(cacheKey); await r2.delete(cacheKey);
// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) { } catch (e) {
console.error('Failed to delete from cache', e); console.error('Failed to delete from cache', e);
} }
@@ -168,12 +121,6 @@ class GitbookIncrementalCache implements IncrementalCache {
} }
} }
async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;
this.localCache = await caches.open('incremental-cache');
return this.localCache;
}
// Utility function to generate keys for R2/Cache API // Utility function to generate keys for R2/Cache API
getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string { getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string {
let key = initialKey; let key = initialKey;
@@ -189,28 +136,13 @@ class GitbookIncrementalCache implements IncrementalCache {
'/' '/'
); );
} }
getCacheUrlKey(cacheKey: string): string {
return `http://cache.local/${cacheKey}`;
}
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
entry: CacheValue<CacheType>
): string[] | undefined {
if ('tags' in entry && entry.tags) {
return entry.tags;
}
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
const rawTags = entry.meta.headers['x-next-cache-tags'];
if (typeof rawTags === 'string') {
return rawTags.split(',');
}
}
if ('value' in entry) {
return entry.tags;
}
}
} }
export default new GitbookIncrementalCache(); 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,
});
+15 -10
View File
@@ -1,12 +1,13 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.16.0", "version": "0.19.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/expr": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
@@ -14,7 +15,8 @@
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.6.4", "@modelcontextprotocol/sdk": "^1.17.5",
"@opennextjs/cloudflare": "^1.8.3",
"@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",
@@ -27,6 +29,7 @@
"@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": "^1.2.1",
"bidc": "catalog:",
"bun-types": "^1.1.20", "bun-types": "^1.1.20",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"direction": "^2.0.1", "direction": "^2.0.1",
@@ -38,6 +41,7 @@
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.9", "katex": "^0.16.9",
"mathjax": "^3.2.2", "mathjax": "^3.2.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",
"mdast-util-gfm": "^3.1.0", "mdast-util-gfm": "^3.1.0",
@@ -45,8 +49,9 @@
"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",
"next": "^15.5.0", "motion": "^12.23.12",
"next-themes": "^0.2.1", "next": "15.4.0",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"object-identity": "^0.1.2", "object-identity": "^0.1.2",
@@ -70,8 +75,8 @@
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"zustand": "^5.0.3", "zod": "^3",
"bidc": "catalog:" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.9", "@argos-ci/playwright": "^5.0.9",
@@ -84,7 +89,6 @@
"@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/psi": "^4.1.6",
"@types/react": "18.3.13", "@types/react": "18.3.13",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
@@ -92,7 +96,6 @@
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"postcss": "^8", "postcss": "^8",
"psi": "^4.1.0",
"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",
@@ -104,7 +107,8 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math", "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next", "dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build", "build": "next build",
"start": "next start", "build:local": "GITBOOK_URL=http://localhost:3000 next build",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build", "build:cloudflare": "opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview", "dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
@@ -112,6 +116,7 @@
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium", "e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium", "e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts", "unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"browserslist": [ "browserslist": [
@@ -22,7 +22,7 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers()); const withTracking = shouldTrackEvents(await headers());
return ( return (
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}> <CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<SiteLayout <SiteLayout
context={context} context={context}
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
@@ -5,6 +5,8 @@ import {
generateEmbeddableViewport, generateEmbeddableViewport,
} from '@/components/Embeddable'; } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
interface SiteStaticLayoutProps { interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -14,9 +16,18 @@ export default async function RootLayout({
params, params,
children, children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) { }: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context } = await getEmbeddableStaticContext(await params); const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>; return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
} }
export async function generateViewport({ params }: SiteStaticLayoutProps) { export async function generateViewport({ params }: SiteStaticLayoutProps) {
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents(); const withTracking = shouldTrackEvents();
return ( return (
<CustomizationRootLayout customization={context.customization}> <CustomizationRootLayout context={context}>
<SiteLayout <SiteLayout
context={context} context={context}
withTracking={withTracking} withTracking={withTracking}
@@ -0,0 +1,20 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveLLMsFullTxt } from '@/routes/llms-full';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { page: string }> }
) {
const awaitedParams = await params;
const page = Number(awaitedParams.page);
// If page is not a number, not an integer, or less than 0, return an error
if (Number.isNaN(page) || !Number.isInteger(page) || page < 0) {
return new Response('Invalid page', { status: 400 });
}
const { context } = await getStaticSiteContext(awaitedParams);
return serveLLMsFullTxt(context, page);
}
@@ -1,31 +0,0 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* This route serves a quick demo to test the script.js script.
* It is not used in production.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return new Response(
`
<html>
<head></head>
<body></body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
</html>
`,
{
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
}
);
}
@@ -0,0 +1,129 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* This route serves a quick demo to test the script.js script.
* It is not used in production.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return new Response(
`
<html>
<head>
<meta name="color-scheme" content="light dark">
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.1" x="0.566895" y="208" width="529.567" height="228" rx="16" fill="#A4A7B0"/>
<g opacity="0.2">
<line x1="141.567" y1="472" x2="4.56689" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="472" x2="165.567" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="472" x2="440.067" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="496" x2="4.56689" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="496" x2="218.567" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="496" x2="348.067" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="520" x2="4.56689" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="520" x2="255.901" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="520" x2="315.186" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="520" x2="466.058" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="544" x2="4.56689" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="544" x2="165.567" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="592" x2="4.56689" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="592" x2="208.017" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="592" x2="336.848" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="592" x2="410.574" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="616" x2="4.56689" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="616" x2="150.633" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="616" x2="425.133" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="640" x2="4.56689" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="640" x2="203.457" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="141.567" y1="4" x2="4.56689" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="4" x2="165.567" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="4" x2="440.067" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="28" x2="4.56689" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="28" x2="218.567" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="28" x2="348.067" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="52" x2="4.56689" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="52" x2="255.901" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="52" x2="315.186" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="52" x2="466.058" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="76" x2="4.56689" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="76" x2="165.567" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="124" x2="4.56689" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="124" x2="208.017" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="124" x2="336.848" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="124" x2="410.574" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="148" x2="4.56689" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="148" x2="150.633" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="148" x2="425.133" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="172" x2="4.56689" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="172" x2="203.457" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<rect x="563" y="208" width="156" height="256" rx="16" fill="#A4A7B0" opacity="0.05"/>
<g opacity="0.2">
<line x1="682.214" y1="228" x2="583" y2="228" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="638.472" y1="252" x2="583" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="681.396" y1="252" x2="654.472" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="617.989" y1="276" x2="583" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="276" x2="633.989" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="603.168" y1="300" x2="583" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="657.873" y1="300" x2="619.168" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="637" y1="324" x2="583" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="688.836" y1="324" x2="653" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="657.474" y1="372" x2="583" y2="372" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="633" y1="396" x2="583" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="396" x2="649" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="585.416" y1="420" x2="583" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="621.731" y1="420" x2="601.416" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="420" x2="637.731" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="619.471" y1="444" x2="583" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
</script>
</html>
`,
{
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
}
);
}
@@ -5,6 +5,7 @@ import {
generateEmbeddableViewport, generateEmbeddableViewport,
} from '@/components/Embeddable'; } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
interface SiteStaticLayoutProps { interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -14,9 +15,18 @@ export default async function RootLayout({
params, params,
children, children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) { }: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context } = await getEmbeddableStaticContext(await params); const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents();
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>; return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
} }
export async function generateViewport({ params }: SiteStaticLayoutProps) { export async function generateViewport({ params }: SiteStaticLayoutProps) {
@@ -1,5 +1,6 @@
import type { RouteLayoutParams } from '@/app/utils'; import type { RouteLayoutParams } from '@/app/utils';
import { getAssetURL } from '@/lib/assets'; import { getAssetURL } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable';
import type { CreateGitBookOptions } from '@gitbook/embed'; import type { CreateGitBookOptions } from '@gitbook/embed';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
@@ -14,7 +15,6 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> } { params }: { params: Promise<RouteLayoutParams> }
) { ) {
const { context } = await getEmbeddableStaticContext(await params); const { context } = await getEmbeddableStaticContext(await params);
const maxAge = 7 * 24 * 60 * 60;
const initOptions: CreateGitBookOptions = { const initOptions: CreateGitBookOptions = {
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')), siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
}; };
@@ -45,13 +45,13 @@ export async function GET(
const load = function () { const load = function () {
const style = document.createElement('link'); const style = document.createElement('link');
style.rel = 'stylesheet'; style.rel = 'stylesheet';
style.href = ${JSON.stringify(getAssetURL('embed/index.css'))}; style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
document.head.appendChild(style); document.head.appendChild(style);
const script = d.createElement('script'); const script = d.createElement('script');
script.type = 'text/javascript'; script.type = 'text/javascript';
script.async = true; script.async = true;
script.src = ${JSON.stringify(getAssetURL('embed/index.js'))}; script.src = ${JSON.stringify(getAssetURL(`embed/index.js?v=${buildVersion()}`))};
var latestScript = d.getElementsByTagName('script')[0]; var latestScript = d.getElementsByTagName('script')[0];
latestScript.parentNode.insertBefore(script, latestScript); latestScript.parentNode.insertBefore(script, latestScript);
@@ -70,7 +70,7 @@ export async function GET(
{ {
headers: { headers: {
'Content-Type': 'application/javascript', 'Content-Type': 'application/javascript',
'Cache-Control': `public, max-age=${maxAge}, immutable`, 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
}, },
} }
); );
@@ -0,0 +1,92 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
nextRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
return {
content: results.flatMap((spaceResult) => {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -1,2 +1,3 @@
export * from './useAI'; export * from './useAI';
export * from './useAIChat'; export * from './useAIChat';
export type { RenderAIMessageOptions } from './server-actions';
@@ -72,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
}) { }) {
const { toolCall, context } = props; const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization); const language = getSpaceLanguage(context);
return ( return (
<p> <p>
@@ -102,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
}) { }) {
const { toolCall, context } = props; const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization); const language = getSpaceLanguage(context);
return ( return (
<p> <p>
@@ -130,7 +130,7 @@ async function DescriptionForSearchToolCall(props: {
}) { }) {
const { toolCall, context } = props; const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization); const language = getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel // Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all( const searchResultsWithHrefs = await Promise.all(
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null} ) : null}
</summary> </summary>
{hasResults ? ( {hasResults ? (
<div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11"> <div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1"> <ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => ( {searchResultsWithHrefs.map((result, index) => (
<li <li
@@ -241,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
}) { }) {
const { toolCall, context } = props; const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization); const language = getSpaceLanguage(context);
return ( return (
<p> <p>
@@ -39,7 +39,9 @@ export async function streamRenderAIMessage(
if (message.steps[stepIndex]) { if (message.steps[stepIndex]) {
message.steps = [...message.steps]; message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] }; message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]); callback(message.steps[stepIndex]);
} else { } else {
message.steps = [ message.steps = [
@@ -52,6 +54,7 @@ export async function streamRenderAIMessage(
}, },
}, },
]; ];
// @ts-expect-error
callback(message.steps[stepIndex]); callback(message.steps[stepIndex]);
} }
}; };
+1 -1
View File
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
setSearchState((prev) => ({ setSearchState((prev) => ({
ask: null, // Reset ask as we assume the assistant will handle it ask: null, // Reset ask as we assume the assistant will handle it
query: prev?.query ?? null, query: prev?.query ?? null,
global: prev?.global ?? false, scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
assistant.open(query); assistant.open(query);
@@ -146,7 +146,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({ setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '', ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null, query: prev?.query ?? null,
global: prev?.global ?? false, scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat open: false, // Close search popover when opening chat
})); }));
}, [setSearchState]); }, [setSearchState]);
@@ -159,7 +159,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({ setSearchState((prev) => ({
ask: null, ask: null,
query: prev?.query ?? null, query: prev?.query ?? null,
global: prev?.global ?? false, scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
}, [setSearchState]); }, [setSearchState]);
@@ -374,7 +374,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({ setSearchState((prev) => ({
ask: input.message, ask: input.message,
query: prev?.query ?? null, query: prev?.query ?? null,
global: prev?.global ?? false, scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
} }
@@ -435,7 +435,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({ setSearchState((prev) => ({
ask: '', ask: '',
query: prev?.query ?? null, query: prev?.query ?? null,
global: prev?.global ?? false, scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
}, [setSearchState]); }, [setSearchState]);
@@ -1,135 +0,0 @@
'use client';
import {
CopyMarkdown,
OpenAIAssistant,
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { useRef } from 'react';
import { useAI } from '../AI';
interface AIActionsDropdownProps {
markdownPageUrl: string;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*/
export function AIActionsDropdown(props: AIActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
const language = useLanguage();
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
<ButtonGroup ref={ref} className={props.className}>
<DefaultAction {...props} />
{props.actions.markdown || props.actions.externalAI ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon={
<Icon
icon="chevron-down"
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
/>
}
label={tString(language, 'more')}
iconOnly
size="xsmall"
variant="secondary"
className="bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
) : null}
</ButtonGroup>
) : null;
}
/**
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
const { markdownPageUrl, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
return (
<>
{assistants.map((assistant) => (
<OpenAIAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
))}
{actions.markdown ? (
<>
<DropdownMenuSeparator className="first:hidden" />
<CopyMarkdown
isDefaultAction={!assistants.length}
markdownPageUrl={markdownPageUrl}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
{actions.externalAI ? (
<>
<DropdownMenuSeparator className="first:hidden" />
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
</>
);
}
/**
* A default action shown as a quick-access button beside the dropdown menu
*/
function DefaultAction(props: AIActionsDropdownProps) {
const { markdownPageUrl, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
if (assistants.length) {
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
}
if (actions.markdown) {
return (
<CopyMarkdown
isDefaultAction={!assistants.length}
markdownPageUrl={markdownPageUrl}
type="button"
/>
);
}
if (actions.externalAI) {
return (
<>
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
</>
);
}
}
@@ -1,8 +0,0 @@
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
<title>ChatGPT</title>
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
</svg>
);
}
@@ -1,19 +0,0 @@
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 256 257"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
{...props}
>
<title>Claude</title>
<g>
<path
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
fill="currentColor"
/>
</g>
</svg>
);
}
@@ -1,9 +0,0 @@
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
<title>Markdown</title>
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
</svg>
);
}
@@ -20,6 +20,7 @@ import {
EmbeddableFrameSubtitle, EmbeddableFrameSubtitle,
EmbeddableFrameTitle, EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame'; } from '../Embeddable/EmbeddableFrame';
import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks'; import { useNow } from '../hooks';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { AIChatControlButton } from './AIChatControlButton'; import { AIChatControlButton } from './AIChatControlButton';
@@ -46,17 +47,6 @@ export function AIChat(props: { trademark: boolean }) {
} }
}, [chat.opened]); }, [chat.opened]);
useHotkeys(
'mod+i',
(e) => {
e.preventDefault();
chatController.open();
},
{
enableOnFormTags: true,
}
);
useHotkeys( useHotkeys(
'esc', 'esc',
() => { () => {
@@ -65,6 +55,16 @@ export function AIChat(props: { trademark: boolean }) {
[] []
); );
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
if (!chat.opened) { if (!chat.opened) {
return null; return null;
} }
@@ -224,7 +224,7 @@ export function AIChatBody(props: {
<> <>
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4" className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
style={{ style={{
paddingBottom: `${inputHeight}px`, paddingBottom: `${inputHeight}px`,
}} }}
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useLanguage } from '@/intl/client'; import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate'; import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { Assistant } from '../AI'; import type { Assistant } from '../AI';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
@@ -12,8 +11,9 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
export function AIChatButton(props: { export function AIChatButton(props: {
assistant: Assistant; assistant: Assistant;
showLabel?: boolean; showLabel?: boolean;
withShortcut?: boolean;
}) { }) {
const { assistant, showLabel = true } = props; const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage(); const language = useLanguage();
return ( return (
@@ -23,16 +23,21 @@ export function AIChatButton(props: {
iconOnly={!showLabel} iconOnly={!showLabel}
size="medium" size="medium"
variant="header" variant="header"
className={tcls('h-9 px-2.5')} className="h-9 px-2.5 max-md:[&_.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)}
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" /> {withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div> </div>
} }
onClick={() => assistant.open()} onClick={() => assistant.open()}
> >
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null} {showLabel ? t(language, 'ask') : null}
</Button> </Button>
); );
} }
@@ -140,7 +140,7 @@ export function AIChatInput(props: {
<span className="leading-none"> <span className="leading-none">
{t(language, 'ai_chat_context_title')} {t(language, 'ai_chat_context_title')}
</span> </span>
<Icon icon="question-circle" className="size-3" /> <Icon icon="question-circle" className="size-3 shrink-0" />
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
</HoverCardRoot> </HoverCardRoot>
@@ -1,3 +1,5 @@
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api'; import { AIMessageRole } from '@gitbook/api';
import type React from 'react'; import type React from 'react';
@@ -46,17 +48,9 @@ export function AIChatMessages(props: {
{message.content ? message.content : null} {message.content ? message.content : null}
{isLastMessage && chat.loading ? ( {isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2"> <div className="flex w-full animate-fade-in-slow flex-col gap-2">
{Array.from({ length: 7 }).map((_, index) => ( {!message.content ? <HoldMessage /> : null}
<div <LoadingSkeleton />
key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
</div> </div>
) : null} ) : null}
@@ -86,3 +80,55 @@ export function AIChatMessages(props: {
</> </>
); );
} }
function HoldMessage() {
const language = useLanguage();
return (
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
{tString(language, 'ai_chat_hold_message_1')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
style={{
animationDelay: `${5000 + index * 200}ms`,
}}
>
{word}{' '}
</span>
))}
{tString(language, 'ai_chat_hold_message_2')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-fade-in-slow"
style={{
animationDelay: `${10000 + index * 200}ms`,
}}
>
{word}{' '}
</span>
))}
</div>
);
}
function LoadingSkeleton() {
return (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
</div>
);
}
@@ -0,0 +1,98 @@
'use client';
import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { createContext, useContext } from 'react';
import type { AdaptiveVisitorClaims } from './types';
/**
* In-memory cache of visitor claim readers keyed by contextId.
*/
const adaptiveVisitorReaderCache = new Map<
string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
>();
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
try {
result = await promise;
} catch {
result = null;
}
})();
return {
read() {
if (result === undefined) {
throw suspender;
}
return result;
},
};
}
/**
* Return an adaptive visitor claims cached reader for a given endpoint URL and contextId.
*/
function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
let reader = adaptiveVisitorReaderCache.get(contextId);
if (!reader) {
const promise = (async () => {
try {
const res = await fetch(url);
if (!res.ok) {
return null;
}
return await res.json<AdaptiveVisitorClaims>();
} catch {
return null;
}
})();
reader = createResourceReader(promise);
adaptiveVisitorReaderCache.set(contextId, reader);
}
return reader;
}
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
/**
* Provide context to adapt site based on visitor claims.
*/
export function AdaptiveVisitorContextProvider(
props: React.PropsWithChildren<{
visitorClaimsURL: string;
contextId: GitBookSiteContext['contextId'] | undefined;
}>
) {
const { visitorClaimsURL, contextId, children } = props;
const getAdaptiveVisitorClaims = React.useCallback(() => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorContext.Provider>
);
}
/**
* Hook that returns a suspensable getter for adaptive visitor claims data.
*/
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
@@ -0,0 +1,3 @@
export * from './types';
export * from './utils';
export * from './AdaptiveVisitorContextProvider';
@@ -0,0 +1,9 @@
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
unsigned: Record<string, unknown>;
};
export type AdaptiveVisitorClaims = {
visitor: {
claims: AdaptiveVisitorClaimsData;
};
};
@@ -0,0 +1,28 @@
import type { Variables } from '@gitbook/api';
import type { AdaptiveVisitorClaims } from './types';
/**
* Return an evaluation context to evaluate expressions.
*/
export function createExpressionEvaluationContext(args: {
visitorClaims: AdaptiveVisitorClaims | null;
variables: {
space?: Variables;
page?: Variables;
};
}) {
const { visitorClaims, variables } = args;
return {
...(visitorClaims ? visitorClaims : {}),
space: {
vars: variables.space ?? {},
},
...(variables.page
? {
page: {
vars: variables.page ?? {},
},
}
: {}),
};
}
@@ -1,142 +1,63 @@
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { Icon } from '@gitbook/icons'; import { AdminToolbarClient } from './AdminToolbarClient';
import { headers } from 'next/headers'; import type { AdminToolbarContext } from './types';
import React from 'react';
import { tcls } from '@/lib/tailwind'; export interface AdminToolbarProps {
import { DateRelative } from '../primitives';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
interface AdminToolbarProps {
context: GitBookSiteContext; context: GitBookSiteContext;
} }
function ToolbarLayout(props: { children: React.ReactNode }) {
return (
<div
className={tcls(
'fixed',
'bottom-5',
'left-1/2',
'z-50',
'transform',
'-translate-x-1/2',
'rounded-full',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'shadow-lg',
'min-h-10',
'min-w-40',
'p-2',
'max-w-md',
'border-tint-12/1',
'backdrop-blur-md'
)}
>
<React.Suspense fallback={null}>{props.children}</React.Suspense>
</div>
);
}
/** /**
* Toolbar with information for the content admin when previewing a revision or change-request. * Server component that determines what type of toolbar to show and passes data to client component
*/ */
export async function AdminToolbar(props: AdminToolbarProps) { export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props; const { context } = props;
const mode = (await headers()).get('x-gitbook-mode');
if (mode === 'multi-id') { // Create a minimal context to avoid serializing and passing too many data to the client
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard. const minimalContext: AdminToolbarContext = {
return null; organizationId: context.organizationId,
} revisionId: context.revisionId,
space: {
id: context.space.id,
revision: context.space.revision,
urls: {
app: context.space.urls.app,
},
},
changeRequest: context.changeRequest
? {
id: context.changeRequest.id,
number: context.changeRequest.number,
subject: context.changeRequest.subject,
revision: context.changeRequest.revision,
updatedAt: context.changeRequest.updatedAt,
createdBy: {
displayName: context.changeRequest.createdBy.displayName,
},
urls: {
app: context.changeRequest.urls.app,
},
}
: null,
revision: {
createdAt: context.revision.createdAt,
urls: {
app: context.revision.urls.app,
},
git: context.revision.git
? {
url: context.revision.git.url,
}
: null,
},
site: {
id: context.site.id,
title: context.site.title,
urls: {
app: context.site.urls.app,
published: context.site.urls.published,
},
},
};
if (context.changeRequest) { return <AdminToolbarClient context={minimalContext} />;
return <ChangeRequestToolbar context={context} />;
}
if (context.revisionId !== context.space.revision) {
return <RevisionToolbar context={context} />;
}
return null;
}
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { space, changeRequest } = context;
if (!changeRequest) {
return null;
}
return (
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-tint-2 text-xs dark:text-tint-11">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={space.id}
changeRequestId={changeRequest.id}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
}
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { revision } = context;
return (
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-tint-2 text-xs dark:text-tint-11">
{revision.git.message}
</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
} }
@@ -0,0 +1,391 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSeparator,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const visitorSession = useVisitorSession();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
closeSession,
closePersistent,
shouldAutoExpand,
};
if (hidden) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
return null;
}
/**
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
*/
export function ToolbarControlsWrapper(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsProvider value={value}>
<IframeWrapper>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open production site */}
{site.urls.published ? (
<ToolbarButton
title="Open production site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open production site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gears"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit in GitBook"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
}
@@ -0,0 +1,185 @@
.svgLogo {
--logo-fill: var(--color-neutral-100);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
--trace-color: #46474c;
--T: 2s;
shape-rendering: geometricPrecision;
vector-effect: non-scaling-stroke;
}
.static .trace {
animation: none;
fill: var(--logo-fill);
stroke: none;
}
.static .seg {
animation: none;
opacity: 0;
}
/* Base segment animation */
.seg {
opacity: 0;
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
segALen var(--T) linear;
animation-delay: 0.06s;
animation-fill-mode: forwards;
}
.segA {
animation-name: segFade, segAMove, segALen;
}
.segB {
animation-name: segFade, segBMove, segBLen;
}
.segC {
animation-name: segFade, segCMove, segCLen;
}
.trace {
animation: traceFill var(--T) ease both 0.06s;
animation-fill-mode: forwards;
}
@keyframes traceFill {
0% {
fill: transparent;
stroke:var(--trace-color);
}
95% {
fill: transparent;
stroke:var(--trace-color);
}
100% {
fill: var(--logo-fill);
stroke:none;
}
}
/* Segment A animation */
@keyframes segAMove {
0% {
stroke-dashoffset: 0;
stroke: var(--seg-A-color);
}
50% {
stroke-dashoffset: -0.5;
stroke: var(--seg-A-color);
}
95% {
stroke-dashoffset: -1;
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segALen {
0% {
stroke-dasharray: 0.18 0.82;
}
95% {
stroke-dasharray: 0.22 0.78;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment B animation */
@keyframes segBMove {
0% {
stroke-dashoffset: -0.18;
stroke: var(--seg-B-color);
}
50% {
stroke-dashoffset: -0.72;
stroke: var(--seg-B-color);
}
95% {
stroke-dashoffset: -1.18;
stroke: var(--seg-B-color);
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segBLen {
0%,
50%,
95% {
stroke-dasharray: 0.2 0.8;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment C animation */
@keyframes segCMove {
0% {
stroke-dashoffset: -0.38;
stroke: var(--seg-C-color);
}
50% {
stroke-dashoffset: -0.92;
stroke: var(--seg-C-color);
}
95% {
stroke-dashoffset: -1.38;
stroke: var(--seg-C-color);
}
100% {
stroke-dashoffset: 0;
stroke: none;
}
}
@keyframes segCLen {
0% {
stroke-dasharray: 0.22 0.78;
}
95% {
stroke-dasharray: 0.18 0.82;
}
100% {
stroke-dasharray: 1;
}
}
/* Segment fade animation - stays visible at end */
@keyframes segFade {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
100% {
opacity: 1;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.seg {
animation: none;
opacity: 0;
}
.trace {
animation: none;
fill: var(--logo-fill);
stroke:none;
}
}
@@ -0,0 +1,68 @@
import { tcls } from '@/lib/tailwind';
import type React from 'react';
import styles from './AnimatedLogo.module.css';
interface AnimatedLogoProps {
shouldAnimate?: boolean;
}
export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
const { shouldAnimate = true } = props;
return (
<svg
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
width="28"
height="28"
viewBox="0 0 28 28"
aria-hidden="true"
>
{/* Background trace */}
<path
d={dPath}
transform="translate(14,14) scale(0.4)"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
className={styles.trace}
/>
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
<path
className={tcls(styles.seg, styles.segA)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.18 0.82"
strokeDashoffset={0}
/>
<path
className={tcls(styles.seg, styles.segB)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.20 0.80"
strokeDashoffset={-0.18}
/>
<path
className={tcls(styles.seg, styles.segC)}
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="none"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
strokeDasharray="0.22 0.78"
strokeDashoffset={-0.38}
/>
</svg>
);
};
const dPath =
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
@@ -0,0 +1,232 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { motion } from 'motion/react';
import React, { useRef } from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
}
/**
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
*/
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const controls = useToolbarControls();
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
setOpen(false);
};
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
const items = [
controls?.minimize
? {
id: 'minimize',
icon: 'minus',
label: 'Minimize',
onClick: controls.minimize,
}
: null,
controls?.closeSession
? {
id: 'session-close',
icon: 'xmark',
label: 'Close for one session',
onClick: controls.closeSession,
}
: null,
controls?.closePersistent
? {
id: 'persistent-close',
icon: 'ban',
label: "Don't show again",
onClick: controls.closePersistent,
}
: null,
].filter(Boolean) as Array<ArcMenuItem>;
const sharedMotionStyle = motionValues
? {
x: motionValues.x,
}
: undefined;
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={() => {
setOpen((v) => !v);
}}
motionValues={motionValues}
icon="eye-slash"
>
{/* Expanding arc menu */}
{open && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
>
<div
className={tcls(
'pointer-events-none absolute left-0 overflow-visible',
styles.arcMenuPath
)}
ref={ref}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
key={item.icon}
{...item}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
/>
))}
</div>
</motion.div>
)}
</ToolbarButton>
);
}
type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
disabled?: boolean;
className?: string;
iconClassName?: string;
};
export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
label,
disabled,
className,
onClick = () => {},
icon,
iconClassName,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
style={
{
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
} as React.CSSProperties
}
className={tcls(
'group',
'absolute',
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
}
@@ -0,0 +1,27 @@
'use client';
import React from 'react';
interface IframeWrapperProps {
children: React.ReactNode;
}
/**
* Client component that detects if we're in an iframe and conditionally renders children
*/
export function IframeWrapper({ children }: IframeWrapperProps) {
const [isInIframe, setIsInIframe] = React.useState(false);
React.useEffect(() => {
// Check if we're running inside an iframe
const inIframe = window !== window.parent;
setIsInIframe(inIframe);
}, []);
// Don't render children if we're in an iframe (GitBook app preview)
if (isInIframe) {
return null;
}
return children;
}
@@ -1,70 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
import { tcls } from '@/lib/tailwind';
import { ToolbarButton } from './Toolbar';
// We don't show the button if the content has been updated 30s ago or less.
const minInterval = 1000 * 30; // 5 minutes
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshChangeRequestButton(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
updatedAt: number;
}) {
const { updatedAt } = props;
const [visible, setVisible] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const checkForUpdates = useCheckForContentUpdate(props);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
await checkForUpdates();
} finally {
setLoading(false);
setVisible(false);
}
}, [checkForUpdates]);
// Show the button if the content has been updated more than 30s ago.
React.useEffect(() => {
if (updatedAt < Date.now() - minInterval) {
setVisible(true);
}
}, [updatedAt]);
// 30sec after being hidden, we show the button again
React.useEffect(() => {
if (!visible) {
const timeout = setTimeout(() => {
setVisible(true);
}, minInterval);
return () => clearTimeout(timeout);
}
}, [visible]);
if (!visible) {
return null;
}
return (
<ToolbarButton
title="Refresh"
onClick={(event) => {
event.preventDefault();
refresh();
}}
>
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
</ToolbarButton>
);
}
@@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshContentButton(props: {
refreshForUpdates: () => Promise<void>;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
const [loading, setLoading] = React.useState(false);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
await refreshForUpdates();
} finally {
setLoading(false);
}
}, [refreshForUpdates]);
return (
<ToolbarButton
title="Refresh for latest changes"
onClick={(event) => {
if (loading) {
return;
}
event.preventDefault();
refresh();
}}
disabled={loading}
motionValues={motionValues}
icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined}
/>
);
}
@@ -0,0 +1,40 @@
.arcMenu {
--arc-width: 505px;
--arc-height: 400px;
--arc-radius: 34%;
--start-distance: -240px;
--spread-distance: 45px;
}
.arcMenuPath {
bottom: calc(var(--arc-height) / -2);
width: var(--arc-width);
height: var(--arc-height);
border-radius: var(--arc-radius);
transform: translate(0%, 0%);
}
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: forwards;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
}
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
}
}
@@ -1,65 +1,343 @@
'use client'; 'use client';
import {
import type * as React from 'react'; AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { Tooltip } from '../primitives';
import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
export function Toolbar(props: { children: React.ReactNode }) { const DURATION_LOGO_APPEARANCE = 2000;
const { children } = props; const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, label, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
setIsReady(true);
};
if (document.readyState === 'complete') {
handleLoad();
} else {
window.addEventListener('load', handleLoad);
return () => window.removeEventListener('load', handleLoad);
}
}, []);
// After toolbar appears, wait, then show the full content
React.useEffect(() => {
if (!isReady || autoExpandTriggeredRef.current) {
return;
}
if (!shouldAutoExpand) {
// When we already know the toolbar should stay expanded (e.g. the user previously
// opened it this session) we short-circuit the auto-expand animation and immediately
// render the expanded state without replaying the logo animation.
autoExpandTriggeredRef.current = true;
setShouldAnimateLogo(false);
return;
}
autoExpandTriggeredRef.current = true;
// On a fresh session we let the toolbar appear in its compact form, play the logo
// animation, and only then expand the toolbar. The timeout mirrors the duration of the
// logo animation so both transitions feel connected.
const expandAfterTimeout = setTimeout(() => {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
return () => clearTimeout(expandAfterTimeout);
}, [isReady, onMinifiedChange, shouldAutoExpand]);
React.useEffect(() => {
if (!minified) {
// Any manual expansion should stop the logo animation so the icon stays in its
// “settled” state once the toolbar is open.
setShouldAnimateLogo(false);
}
}, [minified]);
// Don't render anything until page is ready
if (!isReady) {
return null;
}
return ( return (
<div <Tooltip label={label}>
className={tcls( <motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
'flex', <AnimatePresence mode="wait">
'flex-row', <motion.div
'items-center', onClick={() => {
'gap-4', if (minified) {
'text-sm', setShouldAnimateLogo(false);
'px-4', onMinifiedChange(false);
'py-1', }
'rounded-full', }}
'truncate', layout
'text-tint-1', transition={toolbarEasings.spring}
'dark:text-tint-12' className={tcls(
)} minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
> 'flex',
{children} 'items-center',
</div> 'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
); );
} }
export function ToolbarBody(props: { children: React.ReactNode }) { export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col gap-1">{props.children}</div>; return <div className="flex flex-col items-start px-3">{props.children}</div>;
} }
export function ToolbarButtonGroups(props: { children: React.ReactNode }) { export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
return <div className="flex flex-row gap-2">{props.children}</div>; const { children } = props;
} const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { buttonMotionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length,
containerRef,
});
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
const { children, ...rest } = props;
return ( return (
<a <motion.div
{...rest} ref={containerRef}
className={tcls( variants={toolbarEasings.parent}
'flex', initial="hidden"
'flex-col', animate="show"
'items-center', className="flex items-center gap-1 overflow-visible pr-2 pl-4"
'justify-center',
'size-11',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1', // Equal to text-tint-strong in dark mode
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
'hover:bg-tint-12',
'dark:hover:bg-tint-1',
'hover:shadow-lg',
'cursor-pointer'
)}
> >
{children} {buttonChildren.map((child, index) => {
</a> const childEl = child as React.ReactElement;
const childKey = childEl.key ?? `toolbar-button-${index}`;
return (
<ToolbarButtonWrapper
key={childKey}
child={childEl}
rawMotionValues={buttonMotionValues[index]}
/>
);
})}
</motion.div>
);
}
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
motionValues?: {
scale: MotionValue<number>;
x: MotionValue<number>;
};
icon: IconName;
iconClassName?: string;
title?: React.ReactNode;
children?: React.ReactNode;
}
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const {
title,
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion();
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
</motion.div>
);
});
ToolbarButton.displayName = 'ToolbarButton';
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
}) {
const { child, rawMotionValues } = props;
// Convert the raw motion values to smooth spring easings
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, {
stiffness: 400,
damping: 30,
});
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 });
// Sync springs with raw motion values
React.useEffect(() => {
if (!rawMotionValues) return;
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
return () => {
unsubScale();
unsubX();
};
}, [rawMotionValues, springScale, springX]);
const motionValues = {
scale: springScale,
x: springX,
};
return React.cloneElement(child, {
motionValues,
});
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
<ToolbarTitleSuffix title={props.suffix} />
</div>
);
}
function ToolbarTitlePrefix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(0)}
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
);
}
function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
);
}
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
); );
} }
@@ -0,0 +1,27 @@
'use client';
import React from 'react';
export interface ToolbarControlsContextValue {
minimize: () => void;
closeSession?: () => void;
closePersistent?: () => void;
shouldAutoExpand?: boolean;
}
const ToolbarControlsContext = React.createContext<ToolbarControlsContextValue | null>(null);
/*
* Provides reusable state setters (mainly for hiding/showing the toolbar) for the toolbar controls propagated through to the children
*/
export function ToolbarControlsProvider(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsContext.Provider value={value}>{children}</ToolbarControlsContext.Provider>
);
}
export function useToolbarControls() {
return React.useContext(ToolbarControlsContext);
}
@@ -1 +1,6 @@
export * from './AdminToolbar'; export * from './AdminToolbar';
export * from './AdminToolbarClient';
export * from './IframeWrapper';
export * from './Toolbar';
export * from './transitions';
export * from './utils';
@@ -0,0 +1,73 @@
import type { Transition } from 'motion/react';
// Slight bounce but minimal overshoot esp. at the minimized state.
const spring = {
type: 'spring' as const,
stiffness: 130,
damping: 19,
mass: 1,
};
const parent = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
delayChildren: 0.4,
staggerChildren: 0.1,
},
},
};
const staggeringChild = {
hidden: {
opacity: 0,
scale: 0.7,
},
show: {
opacity: 1,
scale: 1,
transition: {
duration: 0.3,
type: 'spring',
} as Transition,
},
};
export const minifyButtonAnimation = {
initial: {
scale: 0.5,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
},
exit: {
scale: 0.5,
opacity: 0,
},
};
export const getCopyVariants = (position: number) => {
return {
initial: {
opacity: 0,
x: -10,
},
animate: {
opacity: 1,
x: 0,
},
transition: {
duration: 0.1,
delay: position / 10 + 0.3,
} as Transition,
};
};
export const toolbarEasings = {
spring,
parent,
staggeringChild,
};
@@ -0,0 +1,60 @@
/*
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
*/
export type MinimalChangeRequest = {
id: string;
number: number;
subject: string | null;
revision: string;
updatedAt: string;
createdBy: {
displayName: string;
};
urls: {
app: string;
};
};
export type MinimalRevision = {
createdAt: string;
urls: {
app: string;
};
git?: {
url: string | undefined;
} | null;
};
export type MinimalSpace = {
id: string;
revision: string;
urls: {
app: string;
};
};
export type MinimalSite = {
id: string;
title: string;
urls: {
app: string;
published: string | undefined;
};
};
export type AdminToolbarContext = {
organizationId: string;
revisionId: string;
space: MinimalSpace;
changeRequest: MinimalChangeRequest | null;
revision: MinimalRevision;
site: MinimalSite;
};
export interface AdminToolbarClientProps {
context: AdminToolbarContext;
onSessionClose?: () => void;
onPersistentClose?: () => void;
onToggleMinify?: () => void;
}
@@ -0,0 +1,237 @@
import { type MotionValue, motionValue } from 'motion/react';
import React from 'react';
interface MagnificationConfig {
/** Size of each button in pixels - used for spacing calculations */
buttonSize?: number;
/** Maximum scale factor for buttons when mouse is directly over them */
maxScale?: number;
/** Distance in pixels from mouse where buttons start scaling */
influenceRadius?: number;
/** Multiplier for spacing between buttons - higher values create more space */
spacingMultiplier?: number;
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
scaleExponent?: number;
/** Padding around container for mouse detection in pixels */
padding?: number;
}
const defaultConfig: Required<MagnificationConfig> = {
buttonSize: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges
};
const resetMotionValues = (
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }>
) => {
motionValues.forEach(({ scale, x }) => {
scale.set(1);
x.set(0);
});
};
const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions
buttons.forEach((button) => {
button.style.transform = '';
});
return buttons.map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
width: rect.width,
top: rect.top,
height: rect.height,
};
});
};
const calculateScale = (
mouseX: number,
buttonCenterX: number,
containerRect: DOMRect,
config: Required<MagnificationConfig>
) => {
// Calculate only X-axis distance from mouse to button center
const distance = Math.abs(mouseX - buttonCenterX);
if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only)
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX;
// Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8;
let baseScale: number;
if (distance <= heartZoneRadius) {
// Within the heart zone - always at max scale
baseScale = config.maxScale;
} else {
// Outside heart zone - scale from max to 1 based on distance
const outerRadius = config.influenceRadius - heartZoneRadius;
const outerDistance = distance - heartZoneRadius;
const progress = Math.min(outerDistance / outerRadius, 1);
const exponentialProgress = (1 - progress) ** config.scaleExponent;
baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
}
// Only apply entry transition if we're very close to the edge (within 10px)
// This prevents the entry logic from interfering with normal scaling
if (distanceFromEdge < 10) {
const entryProgress = distanceFromEdge / 10;
return 1 + (baseScale - 1) * entryProgress;
}
return baseScale;
};
const calculateSpacing = (
buttonIndex: number,
buttonEffects: Array<{ scale: number; translateX: number }>,
positions: Array<{ left: number; width: number; top: number; height: number }>,
config: Required<MagnificationConfig>
) => {
const currentPos = positions[buttonIndex];
if (!currentPos) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0;
let rightPush = 0;
buttonEffects.forEach((otherEffect, otherIndex) => {
if (otherIndex === buttonIndex) return;
const otherPos = positions[otherIndex];
if (!otherPos) return;
const otherCenterX = otherPos.left + otherPos.width / 2;
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
// Only apply influence if close enough
if (distanceBetween < config.influenceRadius * 1.5) {
const influenceStrength = Math.max(
0,
1 - distanceBetween / (config.influenceRadius * 1.5)
);
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) {
rightPush += pushAmount;
} else {
leftPush += pushAmount;
}
}
});
return rightPush - leftPush;
};
export function useMagnificationEffect(props: {
childrenCount: number;
containerRef: React.RefObject<HTMLElement>;
config?: MagnificationConfig;
}) {
const { childrenCount, containerRef, config } = props;
const originalPositionsRef = React.useRef<
Array<{ left: number; width: number; top: number; height: number }>
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Create basic motion values that will be consumed by springs in the components
const buttonMotionValues = React.useMemo(() => {
return Array.from({ length: childrenCount }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
}, [childrenCount]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
if (buttons.length !== childrenCount) {
console.error(
`Button count (${buttons.length}) does not match children count (${childrenCount})`
);
return;
}
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
// Capture positions if needed
if (originalPositionsRef.current.length !== buttons.length) {
originalPositionsRef.current = captureButtonPositions(buttons);
}
const containerRect = container.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = event;
// Check if mouse is in range
const isInRange =
mouseX >= containerRect.left - finalConfig.padding &&
mouseX <= containerRect.right + finalConfig.padding &&
mouseY >= containerRect.top - finalConfig.padding &&
mouseY <= containerRect.bottom + finalConfig.padding;
if (!isInRange) {
resetMotionValues(buttonMotionValues);
return;
}
// Calculate scales for all buttons
const buttonEffects = buttons.map((_, index) => {
const pos = originalPositionsRef.current[index];
if (!pos) return { scale: 1, translateX: 0 };
const buttonCenterX = pos.left + pos.width / 2;
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
return { scale, translateX: 0 };
});
// Calculate spacing for all buttons
buttonEffects.forEach((effect, index) => {
effect.translateX = calculateSpacing(
index,
buttonEffects,
originalPositionsRef.current,
finalConfig
);
});
// Update motion values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {
motionValue.scale.set(effect.scale);
motionValue.x.set(effect.translateX);
}
});
};
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
return { buttonMotionValues };
}
@@ -0,0 +1,154 @@
import React from 'react';
import {
getLocalStorageItem,
getSessionStorageItem,
removeSessionStorageItem,
setLocalStorageItem,
setSessionStorageItem,
} from '@/lib/browser/local-storage';
const STORAGE_KEY = 'gitbook_toolbar_closed';
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
type SessionHideReason = 'session' | 'persistent';
/**
* Read the current session hide state. We store whether the toolbar was hidden “for session” or
* “persistently” so we can distinguish between a temporary dismissal and a user preference.
*/
export const getSessionHideState = (): { hidden: boolean; reason?: SessionHideReason } => {
const value = getSessionStorageItem<SessionHideReason | null>(SESSION_STORAGE_KEY, null);
if (value === 'session' || value === 'persistent') {
return { hidden: true, reason: value };
}
return { hidden: false, reason: undefined };
};
/**
* Persist the session hide state. Passing `false` clears the stored preference entirely.
*/
export const setSessionHidden = (value: boolean, reason?: SessionHideReason) => {
if (value && reason) {
setSessionStorageItem(SESSION_STORAGE_KEY, reason);
} else {
removeSessionStorageItem(SESSION_STORAGE_KEY);
}
};
/**
* Retrieve the last minified state from session storage. If no value was ever stored we return
* `undefined`, which signals that the toolbar should auto-expand once the logo animation finishes.
*/
export const getStoredMinified = (): boolean | undefined => {
const stored = getSessionStorageItem<boolean | null>(SESSION_MINIFIED_KEY, null);
if (stored === null || stored === undefined) {
removeSessionStorageItem(SESSION_MINIFIED_KEY);
return undefined;
}
return stored;
};
/**
* Persist the current minified state for the ongoing session.
*/
export const setStoredMinified = (value: boolean) => {
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
};
interface UseToolbarVisibilityOptions {
onPersistentClose?: () => void;
onSessionClose?: () => void;
onToggleMinify?: () => void;
}
export function useToolbarVisibility(options: UseToolbarVisibilityOptions = {}) {
const { onPersistentClose, onSessionClose, onToggleMinify } = options;
const [initialStoredMinified] = React.useState<boolean | undefined>(() =>
typeof window !== 'undefined' ? getStoredMinified() : undefined
);
const shouldAutoExpand = initialStoredMinified === undefined;
const [minified, setMinifiedState] = React.useState<boolean>(() =>
initialStoredMinified !== undefined ? initialStoredMinified : true
);
const [persistentHidden, setPersistentHidden] = React.useState(() =>
getLocalStorageItem(STORAGE_KEY, false)
);
const [sessionReason, setSessionReason] = React.useState<SessionHideReason | undefined>(() => {
const state = getSessionHideState();
return state.hidden ? state.reason : undefined;
});
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleStorage = () => {
setPersistentHidden(getLocalStorageItem(STORAGE_KEY, false));
const state = getSessionHideState();
setSessionReason(state.hidden ? state.reason : undefined);
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
React.useEffect(() => {
if (persistentHidden) {
if (sessionReason !== 'persistent') {
// Sync the session flag so we honour the persistent preference and avoid flashes when
// opening new tabs in the same browsing session.
setSessionHidden(true, 'persistent');
setSessionReason('persistent');
}
return;
}
if (sessionReason === 'persistent') {
// If the persistent preference was cleared we also clear the session flag so the toolbar
// can reappear immediately without requiring a full reload.
setSessionHidden(false);
setSessionReason(undefined);
}
}, [persistentHidden, sessionReason]);
const setMinified = (value: boolean) => {
setMinifiedState(value);
setStoredMinified(value);
onToggleMinify?.();
};
const minimize = () => setMinified(true);
const closeSession = () => {
setSessionHidden(true, 'session');
setSessionReason('session');
onSessionClose?.();
};
const closePersistent = () => {
setLocalStorageItem(STORAGE_KEY, true);
setPersistentHidden(true);
setSessionHidden(true, 'persistent');
setSessionReason('persistent');
onPersistentClose?.();
};
const hidden = persistentHidden || sessionReason === 'session';
return {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
};
}
@@ -34,69 +34,67 @@ export function AnnouncementBanner(props: {
className="theme-bold:bg-header-background pt-4 pb-2" className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet="" data-nosnippet=""
> >
<div className="scroll-nojump"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className={tcls('relative', CONTAINER_STYLE)}>
<div className={tcls('relative', CONTAINER_STYLE)}> <Tag
<Tag href={contentRef?.href ?? ''}
href={contentRef?.href ?? ''} className={tcls(
className={tcls( 'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors', style.container,
style.container, closeable && 'pr-12',
closeable && 'pr-12', hasLink && style.hover
hasLink && style.hover )}
)} insights={
insights={ announcement.link
announcement.link ? {
? { type: 'link_click',
type: 'link_click', link: {
link: { target: announcement.link.to,
target: announcement.link.to, position: SiteInsightsLinkPosition.Announcement,
position: SiteInsightsLinkPosition.Announcement, },
}, }
} : undefined
: undefined }
} >
> <Icon
<Icon icon={style.icon as IconName}
icon={style.icon as IconName} className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`} />
/> <div>
<div> {announcement.message}
{announcement.message} {hasLink ? (
{hasLink ? ( <div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}> {contentRef?.icon ? (
{contentRef?.icon ? ( <span className="mr-1 ml-2 *:inline">
<span className="mr-1 ml-2 *:inline"> {contentRef?.icon}
{contentRef?.icon} </span>
</span> ) : null}
) : null} {announcement.link?.title && (
{announcement.link?.title && ( <span className="mr-1">{announcement.link?.title}</span>
<span className="mr-1">{announcement.link?.title}</span> )}
)} <Icon
<Icon icon={
icon={ announcement.link?.to.kind === 'url'
announcement.link?.to.kind === 'url' ? 'arrow-up-right'
? 'arrow-up-right' : 'chevron-right'
: 'chevron-right' }
} className={tcls('mb-0.5 inline size-3')}
className={tcls('mb-0.5 inline size-3')} />
/> </div>
</div> ) : null}
) : null} </div>
</div> </Tag>
</Tag> {closeable ? (
{closeable ? ( <Button
<Button iconOnly
iconOnly icon="close"
icon="close" label={tString(language, 'close')}
label={tString(language, 'close')} variant="blank"
variant="blank" size="default"
size="default" onClick={dismissAnnouncement}
onClick={dismissAnnouncement} className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`} />
/> ) : null}
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,28 +1,43 @@
'use server'; 'use server';
import { getDataOrNull } from '@/lib/data'; import { getDataOrNull } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions'; import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing'; import { traceErrorOnly } from '@/lib/tracing';
/** /**
* Return true if a change-request has been updated. * Return true if the content has been updated.
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
*/ */
export async function hasContentBeenUpdated(props: { export async function hasContentBeenUpdated(props: {
spaceId: string;
changeRequestId: string;
revisionId: string; revisionId: string;
}) { }) {
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => { return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const changeRequest = await getDataOrNull( const siteURLData = await getSiteURLDataFromMiddleware();
context.dataFetcher.getChangeRequest({
spaceId: props.spaceId, if (siteURLData.changeRequest) {
changeRequestId: props.changeRequestId, const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: siteURLData.space,
changeRequestId: siteURLData.changeRequest,
})
);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
}
const space = await getDataOrNull(
context.dataFetcher.getSpace({
spaceId: siteURLData.space,
shareKey: siteURLData.shareKey,
}) })
); );
if (!changeRequest) { if (!space) {
return false; return false;
} }
return changeRequest.revision !== props.revisionId; return space.revision !== props.revisionId;
}); });
} }
@@ -5,20 +5,71 @@ import React from 'react';
import { hasContentBeenUpdated } from './server-actions'; import { hasContentBeenUpdated } from './server-actions';
/** /**
* Return a callback to check if a change request has been updated and to refresh the page if it has. * Return a callback to check if a content has been updated and to refresh the page if it has.
*/ */
export function useCheckForContentUpdate(props: { export function useCheckForContentUpdate(props: {
spaceId: string; /**
changeRequestId: string; * ID of the revision of the content currently being displayed.
*/
revisionId: string; revisionId: string;
/**
* How long after page load to check for updates.
* @default 5min
*/
checkAfterLoad?: number;
}) { }) {
const { spaceId, changeRequestId, revisionId } = props; const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
const [updated, setUpdated] = React.useState(false);
return React.useCallback(async () => {
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
const checkForUpdates = React.useCallback(async () => {
if (updated) { if (updated) {
// No need to check for updates if we already know the content has been updated
return true;
}
const result = await hasContentBeenUpdated({ revisionId });
setUpdated(result);
return result;
}, [revisionId, updated]);
const refreshForUpdates = React.useCallback(async () => {
if (await checkForUpdates()) {
window.location.reload(); window.location.reload();
} }
}, [spaceId, changeRequestId, revisionId]); }, [checkForUpdates]);
// Check for updates after the page has loaded
React.useEffect(() => {
const timeout = setTimeout(() => {
if (document.visibilityState === 'visible') {
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
checkForUpdates();
}
}, checkAfterLoad);
return () => {
clearTimeout(timeout);
};
}, [checkForUpdates, checkAfterLoad]);
// Check for updates when the tab is becoming visible
React.useEffect(() => {
if (updated) {
// No need to check for updates if we already know the content has been updated
return;
}
const callback = () => {
if (document.visibilityState === 'visible') {
checkForUpdates();
}
};
document.addEventListener('visibilitychange', callback);
return () => {
document.removeEventListener('visibilitychange', callback);
};
}, [checkForUpdates, updated]);
return { refreshForUpdates, updated };
} }
@@ -89,7 +89,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
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',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl', FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle, blockStyle,
]} ]}
isEstimatedOffscreen={isOffscreen} isEstimatedOffscreen={isOffscreen}
@@ -24,6 +24,7 @@ export function Caption(
wrapperStyle?: ClassValue; wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile; block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean; withBorder?: boolean;
withFrame?: boolean;
} & DocumentContextProps } & DocumentContextProps
) { ) {
const { const {
@@ -33,6 +34,7 @@ export function Caption(
context, context,
fit = false, fit = false,
withBorder = false, withBorder = false,
withFrame = false,
wrapperStyle = [ wrapperStyle = [
'relative', 'relative',
'overflow-hidden', 'overflow-hidden',
@@ -44,6 +46,7 @@ export function Caption(
withBorder withBorder
? 'rounded-corners:rounded-sm circular-corners:rounded-2xl after:border-tint-subtle after:border after:rounded circular-corners:after:rounded-2xl rounded-corners:after:rounded-sm dark:after:mix-blend-plus-lighter after:pointer-events-none' ? 'rounded-corners:rounded-sm circular-corners:rounded-2xl after:border-tint-subtle after:border after:rounded circular-corners:after:rounded-2xl rounded-corners:after:rounded-sm dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null, : null,
withFrame && 'p-2',
], ],
style, style,
} = props; } = props;
@@ -62,7 +65,14 @@ export function Caption(
return ( return (
<picture className={tcls('relative', style)}> <picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div> <div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption className={tcls('text-sm', 'text-center', 'mt-2', 'text-tint')}> <figcaption
className={tcls(
'text-xs',
'text-center',
'text-tint',
withFrame ? 'mt-0.5 mb-2.5' : 'mt-2'
)}
>
<Inlines <Inlines
nodes={captionParagraph.nodes} nodes={captionParagraph.nodes}
document={document} document={document}
@@ -3,16 +3,19 @@
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
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 { 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 { CodeBlockRenderer } from './CodeBlockRenderer'; import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight'; import type { HighlightLine, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight'; 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;
}; };
/** /**
@@ -20,11 +23,21 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. * 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 } = props; const { block, 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);
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null); const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false); const [highlighting, setHighlighting] = useState(false);
@@ -80,7 +93,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setHighlighting(true); setHighlighting(true);
import('./highlight').then(({ highlight }) => { import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => { highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) { if (cancelled) {
return; return;
} }
@@ -98,7 +111,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
// Otherwise if the block is not in viewport, we reset to the plain lines // Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null); setLines(null);
}, [isInViewport, block, inlines]); }, [isInViewport, block, inlines, evaluateInlineExpression]);
return ( return (
<CodeBlockRenderer <CodeBlockRenderer
@@ -1,3 +1,5 @@
import * as React from 'react';
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document'; import { getNodeFragmentByType } from '@/lib/document';
@@ -14,32 +16,64 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) { export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props; const { block, document, style, isEstimatedOffscreen, context } = props;
const inlines = getInlines(block); const inlines = getInlines(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
const body = (() => { let hasInlineExpression = false;
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) { const richInlines: RenderedInline[] = inlines
return null; // Exclude inline expressions from rendered inline as they are rendered as code text once evaluated
// and so need to be treated as plain code tokens.
.filter((inline) => {
if (inline.inline.type === 'expression') {
hasInlineExpression = true;
return false;
} }
return ( return true;
<Blocks })
key={index} .map((inline, index) => {
document={document} const body = (() => {
ancestorBlocks={[]} const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
context={context} if (!fragment) {
nodes={fragment.nodes} return null;
style="space-y-4" }
/> return (
); <Blocks
})(); key={index}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
);
})();
return { inline, body }; return { inline, body };
}); });
if (!isEstimatedOffscreen) { if (!isEstimatedOffscreen && !hasInlineExpression) {
// In v2, we render the code block server-side // 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} />;
} }
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />; const variables = context.contentContext
? {
space: context.contentContext?.revision.variables,
page:
'page' in context.contentContext
? context.contentContext.page.variables
: undefined,
}
: {};
return (
<React.Suspense fallback={null}>
<ClientCodeBlock
block={block}
style={style}
inlines={richInlines}
inlineExprVariables={variables}
/>
</React.Suspense>
);
} }
@@ -32,7 +32,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<div <div
ref={ref} ref={ref}
aria-busy={ariaBusy} aria-busy={ariaBusy}
className={tcls('group/codeblock grid grid-flow-col', style)} className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
> >
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]"> <div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? ( {title ? (
@@ -1,7 +1,8 @@
import type { DocumentBlockCode, JSONDocument } from '@gitbook/api'; import type { JSONDocument } from '@gitbook/api';
import { useId } from 'react'; import { useId } from 'react';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { convertCodeStringToBlock } from './utils';
/** /**
* Plain code block with syntax highlighting. * Plain code block with syntax highlighting.
@@ -11,31 +12,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props; const { code, syntax } = props;
const id = useId(); const id = useId();
const block: DocumentBlockCode = { const block = convertCodeStringToBlock({ key: id, code, syntax });
key: id,
object: 'block',
type: 'code',
data: {
syntax,
},
nodes: code.split('\n').map((line) => ({
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{
object: 'leaf',
text: line,
marks: [],
},
],
},
],
})),
};
const document: JSONDocument = { const document: JSONDocument = {
object: 'document', object: 'document',
@@ -64,7 +64,10 @@ export async function preloadHighlight(block: DocumentBlockCode) {
*/ */
export async function highlight( export async function highlight(
block: DocumentBlockCode, block: DocumentBlockCode,
inlines: RenderedInline[] inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): Promise<HighlightLine[]> { ): Promise<HighlightLine[]> {
const langName = getBlockLang(block); const langName = getBlockLang(block);
@@ -74,10 +77,10 @@ export async function highlight(
// - TEMP : language is PowerShell or C++ and browser is Safari: // - TEMP : language is PowerShell or C++ and browser is Safari:
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isnt writable // RegExp#[Symbol.search] throws TypeError when `lastIndex` isnt writable
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772 // Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
return plainHighlight(block, inlines); return plainHighlight(block, inlines, options);
} }
const code = getPlainCodeBlock(block); const code = getPlainCodeBlock(block, undefined, options);
const highlighter = await getSingletonHighlighter({ const highlighter = await getSingletonHighlighter({
langs: [langName], langs: [langName],
@@ -119,7 +122,7 @@ export async function highlight(
currentIndex += 1; // for the \n currentIndex += 1; // for the \n
return { return {
highlighted: Boolean(lineBlock.data.highlighted), highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result, tokens: result,
}; };
}); });
@@ -255,11 +258,17 @@ function matchTokenAndInlines(
return result; return result;
} }
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string { function getPlainCodeBlock(
code: DocumentBlockCode,
inlines?: InlineIndexed[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string {
let content = ''; let content = '';
code.nodes.forEach((node, index) => { code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, content.length, inlines); const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options);
content += lineContent; content += lineContent;
if (index < code.nodes.length - 1) { if (index < code.nodes.length - 1) {
@@ -273,7 +282,10 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
function getPlainCodeBlockLine( function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation, parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
index: number, index: number,
inlines?: InlineIndexed[] inlines?: InlineIndexed[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string { ): string {
let content = ''; let content = '';
@@ -284,7 +296,12 @@ function getPlainCodeBlockLine(
switch (node.type) { switch (node.type) {
case 'annotation': { case 'annotation': {
const start = index + content.length; const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines); content += getPlainCodeBlockLine(
node,
index + content.length,
inlines,
options
);
const end = index + content.length; const end = index + content.length;
if (inlines) { if (inlines) {
@@ -297,6 +314,19 @@ function getPlainCodeBlockLine(
break; break;
} }
case 'expression': { case 'expression': {
const start = index + content.length;
const exprValue =
options?.evaluateInlineExpression?.(node.data.expression) ?? '';
content += exprValue;
const end = start + exprValue.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
break; break;
} }
default: { default: {
@@ -9,9 +9,13 @@ import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'
*/ */
export function plainHighlight( export function plainHighlight(
block: DocumentBlockCode, block: DocumentBlockCode,
inlines: RenderedInline[] inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): HighlightLine[] { ): HighlightLine[] {
const inlinesCopy = Array.from(inlines); const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => { return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => { const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') { if (node.object === 'text') {
@@ -20,6 +24,14 @@ export function plainHighlight(
content: getNodeText(node), content: getNodeText(node),
}; };
} }
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift(); const inline = inlinesCopy.shift();
return { return {
type: 'annotation', type: 'annotation',
@@ -0,0 +1,219 @@
import { describe, expect, it } from 'bun:test';
import { convertCodeStringToBlock } from './utils';
describe('convertCodeStringToBlock', () => {
it('converts plain code string without placeholders', () => {
const result = convertCodeStringToBlock({
key: 'test1',
code: 'console.log("hello");',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test1',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'console.log("hello");', marks: [] }],
},
],
},
],
});
});
it('converts a single placeholder into an expression node', () => {
const result = convertCodeStringToBlock({
key: 'test2',
code: 'const config = { API_KEY: "$$__X-GITBOOK-PREFILL[visitor.claims.apiKey ?? "YOUR_API_KEY"]__$$" }',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test2',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{ object: 'leaf', text: 'const config = { API_KEY: "', marks: [] },
],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.apiKey ?? "YOUR_API_KEY"' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: '" }', marks: [] }],
},
],
},
],
});
});
it('handles multiple placeholders in one line', () => {
const result = convertCodeStringToBlock({
key: 'test3',
code: 'let a = $$__X-GITBOOK-PREFILL[valA]__$$, b = $$__X-GITBOOK-PREFILL[valB]__$$;',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test3',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'let a = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'valA' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ', b = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'valB' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ';', marks: [] }],
},
],
},
],
});
});
it('handles multiple placeholders across different lines', () => {
const result = convertCodeStringToBlock({
key: 'test4',
code: [
'const name = "$$__X-GITBOOK-PREFILL[visitor.claims.userName]__$$";',
'const age = $$__X-GITBOOK-PREFILL[visitor.claims.userAge]__$$;',
'console.log(name, age);',
].join('\n'),
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test4',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'const name = "', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.userName' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: '";', marks: [] }],
},
],
},
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'const age = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.userAge' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ';', marks: [] }],
},
],
},
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{ object: 'leaf', text: 'console.log(name, age);', marks: [] },
],
},
],
},
],
});
});
it('returns an empty code block for empty string', () => {
const result = convertCodeStringToBlock({
key: 'test5',
code: '',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test5',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [],
},
],
});
});
});
@@ -0,0 +1,67 @@
import type { DocumentBlockCode, DocumentBlockCodeLine } from '@gitbook/api';
const PREFILL_WITH_EXPR_REGEX = /\$\$__X-GITBOOK-PREFILL\[(.+?)\]__\$\$/g;
/**
* Convert a raw code string into a `DocumentBlockCode` object representation.
*
* Any placeholder of the form `$$__X-GITBOOK-PREFILL[<expression>]__$$` inside the code
* string is transformed into a DocumentInlineExpression node with its `data.expression` set to the
* extracted `<expression>`.
*/
export function convertCodeStringToBlock(args: {
key: string;
code: string;
syntax: string;
}): DocumentBlockCode {
const { key, code, syntax } = args;
const lines = code.split('\n').map<DocumentBlockCodeLine>((line) => {
const nodes: DocumentBlockCodeLine['nodes'] = [];
let lastIndex = 0;
for (const match of line.matchAll(PREFILL_WITH_EXPR_REGEX)) {
const [placeholder, expression] = match;
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push({
object: 'text',
leaves: [{ object: 'leaf', text: line.slice(lastIndex, start), marks: [] }],
});
}
if (expression) {
nodes.push({
object: 'inline',
type: 'expression',
data: { expression },
isVoid: true,
});
}
lastIndex = start + placeholder.length;
}
if (lastIndex < line.length) {
nodes.push({
object: 'text',
leaves: [{ object: 'leaf', text: line.slice(lastIndex), marks: [] }],
});
}
return {
object: 'block',
type: 'code-line',
data: {},
nodes,
};
});
return {
key,
object: 'block',
type: 'code',
data: { syntax },
nodes: lines,
};
}
@@ -1,17 +1,59 @@
import { type ClassValue, tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import type { DocumentBlockColumns, Length } from '@gitbook/api'; import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
import React from 'react';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks'; import { Blocks } from '../Blocks';
export function Columns(props: BlockProps<DocumentBlockColumns>) { export function Columns(props: BlockProps<DocumentBlockColumns>) {
const { block, style, ancestorBlocks, document, context } = props; const { block, style, ancestorBlocks, document, context } = props;
const columnWidths = React.useMemo(() => {
const widths = block.nodes.map((block) => {
const width = block.data.width;
return width ? getFractionalWidth(width) : 0;
});
const totalWidth = widths.reduce<number>((acc, width) => acc + width, 0);
// If not all columns widths are set, distribute the remaining widths as equally as we can
if (totalWidth < 1.0 && widths.some((width) => width === 0)) {
const unsetWidths = widths.filter((width) => width === 0);
let remainingWidth = 1.0 - totalWidth;
let unsetWidthsLength = unsetWidths.length;
widths.forEach((width, index) => {
if (width === 0) {
const calculatedWidth =
Math.round((remainingWidth / unsetWidthsLength) * COLUMN_DIVISIONS) /
COLUMN_DIVISIONS;
widths[index] = calculatedWidth; // Assign width to empty columns
unsetWidthsLength--;
remainingWidth -= calculatedWidth;
}
});
}
return widths;
}, [block.nodes]);
return ( return (
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}> <div
{block.nodes.map((columnBlock) => { className={tcls(
const width = columnBlock.data.width; 'grid w-full grid-cols-1 gap-x-8 gap-y-4 md:grid-flow-col md:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] md:grid-rows-1',
const { className, style } = transformLengthToCSS(width); style
)}
style={{ '--grid-slices': COLUMN_DIVISIONS } as React.CSSProperties}
>
{block.nodes.map((columnBlock, index) => {
const columnWidth = columnWidths[index];
return ( return (
<Column key={columnBlock.key} className={className} style={style}> <Column
key={columnBlock.key}
width={
columnBlock.data.width ??
(columnWidth
? { value: columnWidth * 100, unit: '%' }
: { value: 0, unit: '%' })
}
verticalAlignment={columnBlock.data.verticalAlignment}
>
<Blocks <Blocks
key={columnBlock.key} key={columnBlock.key}
nodes={columnBlock.nodes} nodes={columnBlock.nodes}
@@ -29,51 +71,47 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
export function Column(props: { export function Column(props: {
children?: React.ReactNode; children?: React.ReactNode;
className?: ClassValue; width: Length;
style?: React.CSSProperties; verticalAlignment?: VerticalAlignment;
}) { }) {
const { width, verticalAlignment } = props;
const { className, style } = transformLengthToCSS(width) ?? {};
return ( return (
<div className={tcls('flex-col', props.className)} style={props.style}> <div
className={tcls(
'flex flex-col',
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
'justify-start',
verticalAlignment === VerticalAlignment.Middle && 'justify-center',
verticalAlignment === VerticalAlignment.Bottom && 'justify-end',
className
)}
style={style}
>
{props.children} {props.children}
</div> </div>
); );
} }
function transformLengthToCSS(length: Length | undefined) { const COLUMN_DIVISIONS = 12;
if (!length) {
return { className: ['md:w-full'] }; // default to full width if no length is specified
}
export function transformLengthToCSS(length: Length) {
if (typeof length === 'number') { if (typeof length === 'number') {
return { style: undefined }; // not implemented yet with non-percentage lengths return; // not implemented yet with non-percentage lengths
} }
if (length.unit === '%') { if (length.unit === '%') {
return { return {
className: [ className: 'md:flex-shrink-0 md:[grid-column:var(--grid-col)]',
'md:shrink-0', style: {
COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))], '--grid-col': `auto / span ${Math.round(length.value * 0.01 * COLUMN_DIVISIONS)}`,
], } as React.CSSProperties,
}; };
} }
return { style: undefined }; // not implemented yet with non-percentage lengths
} }
// Tailwind CSS classes for column widths. function getFractionalWidth(length: Length) {
// The index of the array corresponds to the percentage width of the column. if (typeof length === 'number') {
const COLUMN_WIDTHS = [ return 0;
'md:w-0', }
'md:w-1/12', return length.value / 100;
'md:w-2/12', }
'md:w-3/12',
'md:w-4/12',
'md:w-5/12',
'md:w-6/12',
'md:w-7/12',
'md:w-8/12',
'md:w-9/12',
'md:w-10/12',
'md:w-11/12',
'md:w-full',
];
@@ -3,17 +3,28 @@ import { Icon, type IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { Block, type BlockProps } from './Block'; import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks'; import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
export function Hint(props: BlockProps<DocumentBlockHint>) { export function Hint({
const { block, style, ancestorBlocks, ...contextProps } = props; block,
style,
ancestorBlocks,
...contextProps
}: BlockProps<DocumentBlockHint>) {
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info; const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstLine = getBlockTextStyle(block.nodes[0]); const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
const firstNode = block.nodes[0]; const language = contextProps.context.contentContext
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(block.nodes[0].type); ? getSpaceLanguage(contextProps.context.contentContext)
: languages.en;
const label = tString(language, `hint_${block.data.style}`);
return ( return (
<div <div
@@ -35,6 +46,8 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
style style
)} )}
aria-label={label}
role="note"
> >
<div <div
className={tcls( className={tcls(
@@ -11,8 +11,8 @@ import type { DocumentContext } from './DocumentView';
export function Images(props: BlockProps<DocumentBlockImages>) { export function Images(props: BlockProps<DocumentBlockImages>) {
const { document, block, style, context, isEstimatedOffscreen } = props; const { document, block, style, context, isEstimatedOffscreen } = props;
const isMultipleImages = block.nodes.length > 1; const hasMultipleImages = block.nodes.length > 1;
const { align = 'center' } = block.data; const { align = 'center', withFrame } = block.data;
return ( return (
<div <div
@@ -24,7 +24,15 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
align === 'center' && 'justify-center', align === 'center' && 'justify-center',
align === 'right' && 'justify-end', align === 'right' && 'justify-end',
align === 'left' && 'justify-start', align === 'left' && 'justify-start',
isMultipleImages && ['grid', 'grid-flow-col'] 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) => ( {block.nodes.map((node: any, _i: number) => (
@@ -36,6 +44,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
siblings={block.nodes.length} siblings={block.nodes.length}
context={context} context={context}
isEstimatedOffscreen={isEstimatedOffscreen} isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
/> />
))} ))}
</div> </div>
@@ -62,8 +71,9 @@ async function ImageBlock(props: {
context: DocumentContext; context: DocumentContext;
siblings: number; siblings: number;
isEstimatedOffscreen: boolean; isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) { }) {
const { block, context, isEstimatedOffscreen } = props; const { block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([ const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null, context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
@@ -77,33 +87,66 @@ async function ImageBlock(props: {
} }
return ( return (
<Caption {...props} fit> <div className={tcls('relative', 'overflow-hidden')}>
<Image {/* Frame grid */}
alt={block.data.alt ?? ''} {withFrame && (
sizes={imageBlockSizes} <div
resize={context.contentContext?.imageResizer} className={tcls(
sources={{ 'absolute',
light: { '-top-0.5',
src: src.href, '-left-0.5',
size: src.file?.dimensions, 'right-px',
}, 'bottom-px',
dark: darkSrc 'opacity-40',
? { 'dark:opacity-[0.1]',
src: darkSrc.href, 'bg-[length:24px_24px,24px_24px]',
size: darkSrc.file?.dimensions, 'bg-[linear-gradient(to_right,_rgb(234,235,238)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(234,235,238)_1px,_transparent_1px)]',
} 'dark:bg-[linear-gradient(to_right,_rgb(122,128,139)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(122,128,139)_1px,_transparent_1px)]',
: null, 'bg-repeat'
}} )}
priority={isEstimatedOffscreen ? 'lazy' : 'high'} />
preload )}
zoom
inlineStyle={{ {/* Shadow overlay */}
maxWidth: '100%', {withFrame && (
width: getImageDimension(block.data.width, undefined), <div
height: getImageDimension(block.data.height, 'auto'), className={tcls(
}} 'pointer-events-none absolute inset-0 rounded-2xl',
/> 'shadow-[inset_0_0_10px_10px_rgba(255,255,255,0.9)]',
</Caption> 'dark:shadow-[inset_0_0_10px_10px_rgb(29,29,29)]'
)}
/>
)}
<Caption {...props} fit>
<Image
alt={block.data.alt ?? ''}
sizes={imageBlockSizes}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: src.href,
size: src.file?.dimensions,
},
dark: darkSrc
? {
src: darkSrc.href,
size: darkSrc.file?.dimensions,
}
: null,
}}
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
preload
zoom
inlineStyle={{
maxWidth: '100%',
width: getImageDimension(block.data.width, undefined),
height: getImageDimension(block.data.height, 'auto'),
}}
style={withFrame ? 'rounded-xl' : undefined}
/>
</Caption>
</div>
); );
} }
@@ -5,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView'; import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji'; import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton'; import { InlineButton } from './InlineButton';
import { InlineExpression } from './InlineExpression';
import { InlineIcon } from './InlineIcon'; import { InlineIcon } from './InlineIcon';
import { InlineImage } from './InlineImage'; import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink'; import { InlineLink } from './InlineLink';
@@ -51,9 +52,7 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
case 'icon': case 'icon':
return <InlineIcon {...contextProps} inline={inline} />; return <InlineIcon {...contextProps} inline={inline} />;
case 'expression': case 'expression':
// The GitBook API should take care of evaluating expressions. return <InlineExpression {...contextProps} inline={inline} />;
// We should never need to render them.
return null;
default: default:
return nullIfNever(inline); return nullIfNever(inline);
} }
@@ -0,0 +1,30 @@
import * as React from 'react';
import type { DocumentInlineExpression } from '@gitbook/api';
import type { InlineProps } from '../Inline';
import { InlineExpressionValue } from './InlineExpressionValue';
/**
* Render an inline expression.
*/
export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
const { context, inline } = props;
const { data } = inline;
const variables = context.contentContext
? {
space: context.contentContext?.revision.variables,
page:
'page' in context.contentContext
? context.contentContext.page.variables
: undefined,
}
: {};
return (
<React.Suspense fallback={null}>
<InlineExpressionValue expression={data.expression} variables={variables} />
</React.Suspense>
);
}
@@ -0,0 +1,26 @@
'use client';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useMemo } from 'react';
import type { InlineExpressionVariables } from './types';
import { useEvaluateInlineExpression } from './useEvaluateInlineExpression';
export function InlineExpressionValue(props: {
expression: string;
variables: InlineExpressionVariables;
}) {
const { expression, variables } = props;
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables,
});
const result = useMemo(
() => evaluateInlineExpression(expression),
[expression, evaluateInlineExpression]
);
return <>{result}</>;
}
@@ -0,0 +1,3 @@
export * from './InlineExpression';
export * from './types';
export * from './useEvaluateInlineExpression';
@@ -0,0 +1,6 @@
import type { Variables } from '@gitbook/api';
export interface InlineExpressionVariables {
space?: Variables;
page?: Variables;
}
@@ -0,0 +1,42 @@
import * as React from 'react';
import {
type AdaptiveVisitorClaims,
createExpressionEvaluationContext,
} from '@/components/Adaptive';
import type { Variables } from '@gitbook/api';
import { ExpressionRuntime, formatExpressionResult } from '@gitbook/expr';
/**
* Hook that returns a callback to evaluate an inline expression with visitor data
* and space/page variables as context.
*/
export function useEvaluateInlineExpression(args: {
visitorClaims: AdaptiveVisitorClaims | null;
variables: {
space?: Variables;
page?: Variables;
};
}) {
const { visitorClaims, variables } = args;
const evaluateInlineExpression = React.useMemo(() => {
const runtime = new ExpressionRuntime();
const evaluationContext = createExpressionEvaluationContext({
visitorClaims,
variables,
});
return (expression: string) => {
try {
return formatExpressionResult(
runtime.evaluate(expression, evaluationContext) ?? ''
);
} catch (err) {
console.error('Failed to evaluate expression:', expression, err);
return `{{${expression}}}`;
}
};
}, [variables, visitorClaims]);
return evaluateInlineExpression;
}
@@ -1,10 +1,23 @@
import type { DocumentInlineIcon } from '@gitbook/api'; import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline'; import type { InlineProps } from './Inline';
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;
// @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 <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />; return (
<Icon
icon={icon}
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
/>
);
} }

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