Compare commits

..

133 Commits

Author SHA1 Message Date
Greg Bergé de2d468677 Fix build 2025-01-09 10:03:57 +01:00
Greg Bergé 7f50ad68bb Remove import 2025-01-09 09:49:51 +01:00
Greg Bergé 3a759b14a4 Fix lockfile 2025-01-09 09:46:55 +01:00
Greg Bergé b5e2e3fa8d Setup Sentry with Cloudflare 2025-01-09 09:40:47 +01:00
Greg Bergé fec48ce708 Import Sentry in middleware 2025-01-09 09:21:52 +01:00
Greg Bergé 0784bf5df7 Update wrangler 2025-01-09 08:49:48 +01:00
Greg Bergé f8f485ae25 Add log 2025-01-09 08:48:44 +01:00
Greg Bergé 3491bdc522 Import Sentry in middleware 2025-01-08 17:14:16 +01:00
Greg Bergé 2cda8d4cad Add more logs 2025-01-08 16:51:07 +01:00
Greg Bergé 3fcbe46262 Introduce error on purpose 2025-01-08 16:34:30 +01:00
Greg Bergé 884ee7073f Simplify Sentry setup, add logs 2025-01-08 16:34:27 +01:00
Greg Bergé 88397bf419 Flush Sentry events manually server-side (#2698) 2025-01-08 14:57:15 +01:00
Valentino Hudhra 1138d5935b Add sidebar new background styles (#2645)
Co-authored-by: Zeno Kapitein <admin@zeno.zone>
2025-01-08 14:01:06 +01:00
Greg Bergé 8276ba080e Make cookies access safer (#2694) 2025-01-08 13:12:41 +01:00
Greg Bergé 37d13d80f5 Pass string URL to fetch to avoid errors (#2695) 2025-01-08 13:01:10 +01:00
Greg Bergé 1c97536ab1 Fix Sentry instrumentation (again) (#2697) 2025-01-08 12:28:08 +01:00
Greg Bergé ca8f028cc0 Fix error in header mobile menu (#2696) 2025-01-08 11:09:58 +00:00
Claire Chabas 300f7bfe4b Fix search loading state (#2684) 2025-01-08 11:52:12 +01:00
Greg Bergé 82dc9c40f7 Simplify useHash algo used (#2689) 2025-01-08 11:37:48 +01:00
Greg Bergé 44a20fe5ee Improve scroll listener (#2692) 2025-01-08 11:17:22 +01:00
Greg Bergé b950a64406 improve browser support (#2693) 2025-01-08 11:08:29 +01:00
Greg Bergé c5f374f66f Move instrumentation to src folder (#2691) 2025-01-08 10:55:56 +01:00
Greg Bergé ae99f87db0 Improve emoji setup (#2688) 2025-01-08 10:11:35 +01:00
Greg Bergé 5112e3e79b Fix Sentry instrumentation server-side (#2690) 2025-01-08 09:52:02 +01:00
Greg Bergé c1e27ccacc Fix pass Sentry release properly (#2687) 2025-01-08 09:12:34 +01:00
Greg Bergé b0bd871997 Even safer local storage... (#2686) 2025-01-07 17:57:02 +01:00
Greg Bergé c30bc24fd6 Fix empty sitemap (#2685) 2025-01-07 17:09:51 +01:00
Greg Bergé 648f0e9e84 Various fixes (#2683) 2025-01-07 16:54:10 +01:00
Greg Bergé d2bc5672e0 Set Sentry release (#2682) 2025-01-07 16:36:44 +01:00
Greg Bergé 5c87ec7c2f Safer way to interact with localStorage (#2679) 2025-01-07 15:51:23 +01:00
Greg Bergé d66c184ed2 Ignore errors from event flushing (#2681) 2025-01-07 15:38:18 +01:00
Greg Bergé 12f25d8bfc Fix error from visitor id not set (#2678) 2025-01-07 15:18:05 +01:00
Greg Bergé cbe61397a3 Fix dynamic tabs infinite loop (#2677) 2025-01-07 14:54:28 +01:00
Greg Bergé deb8c54da3 Upgrade Next.js to v15 (#2671) 2025-01-07 10:42:02 +01:00
Greg Bergé 5dab70fab4 Fix "Parser" language syntax highlighting (#2676) 2025-01-07 10:23:25 +01:00
Greg Bergé 665b6bed66 Ignore invalid API calls to getSiteRedirectFromSource API (#2675) 2025-01-07 10:06:31 +01:00
Greg Bergé f4a90defc3 Remove Recoil (#2674) 2025-01-06 16:06:59 +01:00
Greg Bergé 46f63cbd55 Fix code format overriding (#2673)
Co-authored-by: eztaK-red <eztak.red@gmail.com>
2025-01-06 15:15:39 +01:00
Greg Bergé c77142a16f Log component stack in Sentry (#2672) 2025-01-06 13:14:22 +01:00
Steven H eb7c22f52a Revert Scalar to a previous version to mitigate an app crash happening when switching between API pages. (#2670) 2025-01-06 11:57:51 +00:00
Steven H e86e51f06f Fix an issue where the redirects of potentially malicious images were not going through. (#2666) 2024-12-30 14:41:54 +00:00
Steven H db74ea3001 Image optimization endpoint redirects to underlying image URL if the signature is not the latest. (#2665) 2024-12-30 13:08:49 +00:00
Steven H 99579ac29c Fix a vulnerability issue for images using an older version of the image signing parameter. (#2664) 2024-12-30 11:29:30 +00:00
Samy Pessé 2f6540e008 Don't cache VA and adaptive content responses (#2663) 2024-12-27 17:56:42 +01:00
Samy Pessé 72c3b80529 Pass visitor auth token to site insights events (#2662) 2024-12-26 19:06:29 +01:00
Viktor Renkema 8126a83c50 Reintro vertical table lines + hover state (#2653) 2024-12-25 08:41:54 +01:00
Samy Pessé 210c432b37 Fix PDF export crashing (#2661) 2024-12-23 14:52:50 +01:00
Amrit Kahlon f92e90603f Prevent multiple versions of codemirror from loading (#2660)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-12-21 23:46:16 +01:00
Samy Pessé 14172791e9 Track click on links for site insights (#2659) 2024-12-21 18:18:30 +01:00
Samy Pessé e4e2f524d4 Track site insight event when opening scalar client (#2658) 2024-12-21 15:57:10 +01:00
Amrit Kahlon fc7b16f6a7 Update scalar dependency (#2652) 2024-12-20 23:16:11 +01:00
Samy Pessé 6ba3ae74d5 Workaround until referrer is accepted by API as an empty string (#2656) 2024-12-20 19:23:52 +01:00
Samy Pessé c71d1598d8 Insights site events support (#2655) 2024-12-20 19:06:50 +01:00
Greg Bergé 5950657b0e Fix emojis display (#2648) 2024-12-20 11:30:56 +01:00
Samy Pessé 0e1a48cd06 Upgrade bun and setup-bun, switch to text lockfile (#2651)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2024-12-20 11:19:54 +01:00
spastorelli 98245e5f20 Adapt code to pull token from customer backend generated custom cookies (#2650)
Co-authored-by: Steven Hall <steven@gitbook.io>
2024-12-19 18:29:32 +01:00
Zeno Kapitein 0b6ddca981 Fix variant selector for non-default themes (#2649) 2024-12-19 12:28:13 +01:00
spastorelli ea1468c892 Send redirectOnError param to getPublishedContent when token is pulled from cookie (#2626) 2024-12-19 11:16:06 +01:00
Greg Bergé e8e64bf510 Fix bullet list display on full size blocks (#2646) 2024-12-18 13:55:18 +01:00
Greg Bergé aaf8daf0d7 Improve search UI (#2643) 2024-12-18 13:42:47 +01:00
Zeno Kapitein 8af1abc4e1 Improve contrast of search box placeholder (#2639) 2024-12-18 12:14:06 +01:00
Zeno Kapitein 48ab59feb3 Improve contrast of list decoration (#2638) 2024-12-18 11:49:01 +01:00
Samy Pessé f64d5073d4 Version Packages (#2557)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-18 10:25:08 +01:00
Samy Pessé 53b9f10ac8 Prepare @gitbook/proxy to support serving under sub-directory (#2641) 2024-12-18 10:05:31 +01:00
Samy Pessé 75606e4df7 Fix missing env GITBOOK_ASSETS_PREFIX passed to build (#2642) 2024-12-17 21:46:04 +01:00
Claire Chabas 0643b7f351 Improve ask/search Enter key press UX (#2624)
Co-authored-by: Zeno Kapitein <admin@zeno.zone>
2024-12-17 18:08:29 +01:00
Claire Chabas 5b3b4e0614 Fix closing search modal on Escape key press (#2637) 2024-12-17 15:33:36 +01:00
Samy Pessé 8d039d1348 Pass down ancestors instead of computing them (#2635) 2024-12-16 21:11:46 +01:00
Zeno Kapitein 87eea73081 Fix margin and image resolution of header logo (#2633) 2024-12-16 14:34:00 +01:00
Zeno Kapitein 67a6fb4c87 Fix localised search string from wrapping search box (#2630) 2024-12-13 16:48:06 +01:00
Zeno Kapitein 23584c9862 Update header styling (#2613)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2024-12-13 15:51:23 +01:00
Zeno Kapitein 2f767125c4 Add breadcrumbs to page header (#2628) 2024-12-13 15:20:07 +01:00
Greg Bergé c73e07d42d Increase token max length to fix code not highlighted (#2629) 2024-12-13 13:16:55 +01:00
Viktor Renkema 6b5d7e6057 Improve styles of Tables (#2623) 2024-12-13 08:15:52 +01:00
Greg Bergé 076dc48e89 Fix expandable block anchor resolution (#2625) 2024-12-12 23:25:29 +01:00
Greg Bergé bb208ab36a Update JoyPixels (#2619) 2024-12-12 09:05:12 +01:00
Viktor Renkema 60e35001b6 Refactor Table to use flex layouts (#2622) 2024-12-11 21:34:37 +01:00
Greg Bergé 29a0d7ea02 Fix sublinks display on mobile (#2620) 2024-12-11 18:57:55 +01:00
Greg Bergé 1f2b7f7acf Prevent resizing AVIF (not supported by Cloudflare) (#2621) 2024-12-11 15:17:25 +01:00
spastorelli 39e5cb63ef Fix context Id not returned by lookupSpaceByAPI (#2618) 2024-12-11 10:54:17 +01:00
spastorelli 5b5928fb8d Fix VA e2e tests when ran locally (#2617) 2024-12-11 08:34:05 +01:00
Greg Bergé 08068743a5 Remove Next.js cache on CI (#2616) 2024-12-10 18:53:47 +01:00
Greg Bergé 35eae1adcf Fix canonical in a safe way (#2589) 2024-12-10 17:47:57 +01:00
Greg Bergé e85d357abd Fix React DOM warning (#2615) 2024-12-10 17:39:22 +01:00
spastorelli 31396d8109 Adapt GBO cache logic to support users sessions info (#2606) 2024-12-10 14:11:59 +01:00
Zeno Kapitein 664debc0bc Add tint color (#2605)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2024-12-09 17:19:44 +01:00
Viktor Renkema 56c31c76b3 Center-align inline images (#2609) 2024-12-09 13:21:43 +01:00
Viktor Renkema 2d1a71f3d1 Allow cells in tables to use more available space (#2611) 2024-12-09 13:11:05 +01:00
Valentino Hudhra c67cd73058 Add visual tests for tint (#2601) 2024-12-09 12:29:03 +01:00
Greg Bergé 2572cb4352 Pin React versions (#2610) 2024-12-09 11:14:02 +01:00
Steven H c9f6c7ccac Fix an issue where Ask AI answers with too many sources could crash the Worker. (#2608) 2024-12-07 15:23:10 +00:00
Steven Hall b6520b0dbc hotfix: Fix an issue where Ask AI was crashing on some requests. 2024-12-06 22:26:41 +00:00
Steven H 9fe8142117 Fix an issue where Ask AI was erroring due to an object being passed as a param. (#2607) 2024-12-06 21:55:59 +00:00
Steven H 57cdd25b5f Ask AI now goes through the site API instead of spaces. (#2598) 2024-12-06 20:06:49 +00:00
Samy Pessé 1005ee5d52 Prevent resizing the url of an image already resized (#2600) 2024-12-05 16:34:31 +01:00
Valentino Hudhra d9bb9f9a07 Fix cookie banner button being non responsive (#2599) 2024-12-05 14:04:03 +01:00
Greg Bergé 75a6fabe61 Support dropdown without link (#2596) 2024-12-04 12:05:17 +01:00
Greg Bergé 75e29b69cc Allow to work locally (#2597) 2024-12-03 14:49:13 +01:00
Samy Pessé ffd3937538 Fix security issue with images resizing (#2595) 2024-12-03 08:51:46 +01:00
Valentino Hudhra 575587e4c5 Improve footer link styling (#2591) 2024-12-01 07:54:45 +01:00
Brett Jephson 2ce59d7c92 Fix whitespace added with image (#2594) 2024-11-29 17:10:29 +00:00
Brett Jephson 3b3d6e2a73 Add an icon to site sections (#2593) 2024-11-29 16:09:06 +00:00
Zeno Kapitein f2a467fdbf Fix hint block decoration color (#2590) 2024-11-29 16:40:10 +01:00
vibhanshub 867481c66d Support Integers in Response sample (#2592) 2024-11-28 14:19:37 +00:00
spastorelli fc8065b7a8 Revert "Fix canonical URL ending with "/" (#2586)" (#2588) 2024-11-27 13:05:34 +01:00
Greg Bergé 28f1ed9205 Fix canonical URL ending with "/" (#2586) 2024-11-26 21:57:15 +01:00
Viktor Renkema 568266bc9a Add transitions to Sections bg and line (#2491) 2024-11-26 19:59:07 +01:00
Samy Pessé 7739f341b8 Revert "Serve the correct status code for not found and redirects 👌" (#2587) 2024-11-26 18:36:00 +01:00
Greg Bergé 2b501f191c Serve the correct status code for not found and redirects 👌 (#2582) 2024-11-26 18:05:47 +01:00
Zeno Kapitein aa2ed0fda1 Restyle hint block (#2579) 2024-11-26 12:41:47 +01:00
Greg Bergé 51cba074d7 Update shiki (#2583) 2024-11-26 11:50:08 +01:00
Taran Vohra 2c945c663c Get the best possible target space for content refs (#2580) 2024-11-26 10:53:37 +05:30
Viktor Renkema d9a18ecabb Improve contrast ratio for text color on image captions (#2584) 2024-11-25 14:01:09 +01:00
Greg Bergé eab7931afb Parallelize and remove useless Promise.all (#2581) 2024-11-22 15:54:56 +01:00
Taran Vohra 99b6c92377 Resolve site level redirects (#2577) 2024-11-22 12:04:15 +05:30
Zeno Kapitein 4d56f1163b Improve styling of search & ask AI box (#2576) 2024-11-20 16:59:15 +01:00
Brett Jephson 07cf835551 Add a scroll margin to the top when layout has sections (#2574) 2024-11-19 16:34:27 +00:00
Steven H 061c0c1a90 Fix a regression in variant drop-down caused by missing z-index. (#2575) 2024-11-19 17:23:44 +01:00
Anthony Lio 1ed18c05ef Adds missing css variables (#2558)
Co-authored-by: Steven H <steven@gitbook.io>
2024-11-19 16:08:07 +01:00
Steven H ca134c8a60 Fix an issue where the active site section indicator appeared above any dropdowns. (#2568) 2024-11-19 15:16:47 +01:00
Amrit Kahlon 7ba67fd852 chore: scalar bump (#2571)
Co-authored-by: Steven Hall <steven@gitbook.io>
2024-11-19 13:54:35 +01:00
Taran Vohra 7675c2c411 use getPublishedContentSite API (#2570)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-11-15 09:21:08 +01:00
Brett Jephson 7c71363af8 Set adjustFallbackFont to false for mono font (#2569) 2024-11-12 13:03:25 +00:00
Steven H d48926eb2a Fix an issue where the space dropdown was shown under the site sections in Safari. (#2567) 2024-11-05 11:49:33 +00:00
Steven H d843e5e06f Fix an issue where the space dropdown could appear behind the header. (#2566) 2024-11-05 09:47:32 +00:00
Steven H a2e564738e Fix the styling of site section tabs on smaller screens. (#2565) 2024-11-04 17:36:10 +00:00
Greg Bergé 54a797a0b1 Nested list support (#2562) 2024-10-31 14:02:42 +01:00
Amrit Kahlon a78c1ecb4d Bump scalar api client package (#2563) 2024-10-30 11:45:56 -07:00
Amrit Kahlon 4771c78001 Fix Scalar ApiClient routing (#2561) 2024-10-28 12:38:18 -07:00
Samy Pessé b7a5106f36 Disable logs for cache-do (#2560) 2024-10-27 08:16:56 -04:00
Samy Pessé 4bcbdc50ff Output caching headers on RSC/prefetch responses (#2559) 2024-10-26 10:57:52 +02:00
Brett Jephson 5d72b35c7f Improve animation of section tabs (#2553) 2024-10-25 14:00:27 +01:00
Amrit Kahlon ff50ac215c Bump @scalar/api-client-react version (#2555)
Co-authored-by: taranvohra <taranvohra@outlook.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-10-25 12:06:49 +05:30
242 changed files with 6204 additions and 3363 deletions
+1
View File
@@ -0,0 +1 @@
1.1.40
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix empty sitemap
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Adapt code to pull token from customer backend generated custom cookies
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Log component stack in Sentry
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Add an optional client context to get a callback called when the Scalar client is opened for a block.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix pass Sentry release properly
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add support for sidebar background styles
+7
View File
@@ -0,0 +1,7 @@
---
'gitbook': patch
---
Ignore invalid API calls to `getSiteRedirectFromSource` API
To reduce the load on the API and also avoid errors.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Ignore errors from event flushing
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve emoji setup, align with GitBook app
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the redirects of potentially malicious images were not going through.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Make cookies access safer
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve contrast of search box placeholder
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Simplify the `useHash` algo used.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track events for site insights using the new dedicated API.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve colour contrast of list item decoration
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Set Sentry release
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': minor
'gitbook': minor
---
Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-contentkit': minor
'gitbook': minor
---
Send redirectOnError param to getPublishedContent when token is pulled from cookie
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-contentkit': patch
---
Fix potential invalid URL error in react-contentkit
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Avoid error on fetch by passing a string URL
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track clicks on links (header, footer, content) for site insights.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix code format overriding inline link styles
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix emojis display
+7
View File
@@ -0,0 +1,7 @@
---
'gitbook': patch
---
Implement a safer way to interact with localStorage.
If it's disabled on the browser it should not throw error.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix dynamic tabs infinite loop
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Prevent codemirror from loading multiple versions in scalar
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix variant selector contrast for non-default themes
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Updated scalar depdenency
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix search loading state
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix Sentry instrumentation
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Even safer localStorage
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Avoid errors on legacy browsers
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Improve readability of tables with hover style and vertical dividers
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix bullet list display on full size blocks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix "Parser" language syntax highlighting
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix a vulnerability issue for images using an older version of the image signing parameter.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track an event into site insights when visitor is opening the Scalar API client.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve smoothness of scroll listener
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix Sentry instrumentation server-side
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
# Cache browser binaries, cache key is based on Playwright version and OS
- name: 🧰 Cache Playwright browser binaries
uses: actions/cache@v3
uses: actions/cache@v4
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
+18
View File
@@ -0,0 +1,18 @@
name: 'Setup Bun'
description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
# and impacting bandwidth there.
- name: Cache bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
+23 -90
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
env:
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy:
name: Deploy to Cloudflare Pages
@@ -18,28 +18,16 @@ jobs:
checks: write
statuses: write
outputs:
deployment_url: ${{ steps.deploy.outputs.url }}
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
@@ -56,9 +44,10 @@ jobs:
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@v3.11.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -67,7 +56,7 @@ jobs:
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
- name: Archive build output
uses: actions/upload-artifact@v4
@@ -77,12 +66,12 @@ jobs:
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
- name: Update Deployment Status to Success
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }}
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
- name: Find GitHub Comment
@@ -102,65 +91,17 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
body: |
**GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.url }}](${{ steps.deploy.outputs.url }})
PR: [${{ steps.deploy.outputs.alias }}](${{ steps.deploy.outputs.alias }})
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
name: Visual Testing
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
name: Format
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -172,10 +113,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -187,10 +126,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -201,16 +138,14 @@ jobs:
runs-on: ubuntu-latest
name: Build (Open Source)
env:
NPMRC_FONT_AWESOME_TOKEN: ''
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
run: bun install
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
@@ -220,10 +155,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+5 -9
View File
@@ -17,10 +17,8 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -34,7 +32,7 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
@@ -45,10 +43,8 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+20
View File
@@ -78,6 +78,26 @@ Any published GitBook site can be accessed through your local development instan
All pull-requests will be tested against both visual and performances testing to prevent regressions.
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+8 -4
View File
@@ -7,12 +7,12 @@
"turbo": "^2.1.2"
},
"packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
"overrides": {
"@codemirror/state": "6.4.1"
},
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev": "turbo run dev --ui=stream",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint",
@@ -30,5 +30,9 @@
},
"workspaces": [
"packages/*"
]
],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
}
}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/cache-do
## 0.1.1
### Patch Changes
- b7a5106: Disable cloudflare observability in production
## 0.1.0
### Minor Changes
+2 -2
View File
@@ -14,14 +14,14 @@
"default": "./dist/api.js"
}
},
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "3.82.0"
"wrangler": "^3.100.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
+2 -3
View File
@@ -11,8 +11,7 @@ migrations = [
]
[observability]
enabled = true
head_sampling_rate = 0.01
enabled = false
[env.preview]
name = "gitbook-open-cache-preview"
@@ -25,4 +24,4 @@ migrations = [
[env.preview.observability]
enabled = true
head_sampling_rate = 1
head_sampling_rate = 0.1
+40
View File
@@ -1,5 +1,45 @@
# gitbook
## 0.5.0
### Minor Changes
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
- a2e5647: Fix the styling of site section tabs on smaller screens.
### Patch Changes
- 076dc48: Fix expandable block anchore resolution
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
- 664debc: Add support for tint color
- 4d56f11: Update styling of search+ask modal
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
- 2f76712: Add breadcrumbs above page title
- 07cf835: Add scroll margin to the top when there are sections
- 5d72b35: Smoother tab transition for sections
- 7c71363: Don't adjust fallback font for mono font.
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
- 87eea73: Fix margin and image resolution of header logo
- aa2ed0f: Restyle hint blocks
- ffd3937: Fix security issue with image resizing that could be used for phishing
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
- c73e07d: Increase token max length to fix code not highlighted
- 3b3d6e2: Add icons to sections
- 1ed18c0: style: adds missing scalar css variables
- Updated dependencies [b7a5106]
- Updated dependencies [4771c78]
- Updated dependencies [ff50ac2]
- Updated dependencies [867481c]
- Updated dependencies [7ba67fd]
- Updated dependencies [a78c1ec]
- @gitbook/cache-do@0.1.1
- @gitbook/react-openapi@0.7.1
## 0.4.0
### Minor Changes
+646 -19
View File
@@ -1,20 +1,34 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page, BrowserContext } from '@playwright/test';
import deepMerge from 'deepmerge';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import {
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
VISITOR_TOKEN_COOKIE,
} from '@/lib/visitor-token';
import { getContentTestURL } from '../tests/utils';
interface Test {
name: string;
url: string; // URL to visit for testing
cookies?: Parameters<BrowserContext['addCookies']>[0];
run?: (page: Page) => Promise<unknown>; // The test to run
fullPage?: boolean; // Whether the test should be fullscreened during testing
screenshot?: false; // Should a screenshot be stored
@@ -34,6 +48,23 @@ const allLocales: CustomizationLocale[] = [
CustomizationLocale.Zh,
];
const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
const allThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
CustomizationSidebarBackgroundStyle.Default,
CustomizationSidebarBackgroundStyle.Filled,
];
async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
@@ -95,7 +126,9 @@ const testCases: TestsCase[] = [
name: 'Variants dropdown',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
const spaceDrowpdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.waitFor();
},
},
@@ -111,7 +144,9 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
const spaceDrowpdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
@@ -143,9 +178,9 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -166,9 +201,9 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -201,9 +236,9 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
})(),
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -435,30 +470,38 @@ const testCases: TestsCase[] = [
{
name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
tests: allThemeModes.flatMap((theme) => [
{
name: 'Without header',
name: `Without header - Theme ${theme}`,
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.None,
links: [],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: 'With duotone icons',
name: `With duotone icons - Theme ${theme}`,
url:
'page-options/page-with-icon' +
getCustomizationURL({
styling: {
icons: CustomizationIconsStyle.Duotone,
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: 'With header buttons',
name: `With header buttons - Theme ${theme}`,
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.Default,
@@ -475,10 +518,108 @@ const testCases: TestsCase[] = [
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
],
{
name: `Without tint - Default preset - Theme ${theme}`,
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.Default,
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
// Theme-specific tests
...allThemePresets.flatMap((preset) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `With tint - Preset ${preset} - Sidebar ${sidebarStyle} - Theme ${theme}`,
url: getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
sidebar: {
background: sidebarStyle,
list: CustomizationSidebarListStyle.Default,
},
},
header: {
preset,
...(preset === CustomizationHeaderPreset.Custom
? {
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
linkColor: { light: '#4DDE98', dark: '#0C693D' },
}
: {}),
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
{
name: `With tint - Legacy background match - Theme ${theme}`,
url: getCustomizationURL({
styling: {
background: CustomizationBackground.Match,
},
header: {
preset: CustomizationHeaderPreset.Default,
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
]),
},
{
name: 'Ads',
@@ -491,6 +632,55 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Shared space navigation (first site)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-uno/',
tests: [
{
name: 'Navigation to shared space',
url: '',
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
},
],
},
{
name: 'Shared space navigation (second site)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-dos/',
tests: [
{
name: 'Navigation to shared space',
url: '',
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
},
],
},
{
name: 'Site Redirects',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to SSO page',
url: 'a/redirect/to/sso',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
},
],
},
{
name: 'Share links',
baseUrl: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
@@ -698,6 +888,60 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/`,
tests: [
{
name: 'Redirect to fallback on invalid token pulled from cookie',
url: '',
screenshot: false,
cookies: (() => {
const basePath = '/va-site-redirects-fallback/';
const invalidToken = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return [
{
name: getVisitorAuthCookieName(basePath),
value: getVisitorAuthCookieValue(basePath, invalidToken),
httpOnly: true,
},
];
})(),
run: async (page) => {
await expect(page).toHaveURL(/https:\/\/www.google.com/);
},
},
{
name: 'Show error message when invalid token is passed to url',
screenshot: false,
url: (() => {
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(page.locator('pre')).toContainText(
'Error while validating the JWT token. Reason: The token signature is invalid.',
);
},
},
],
},
{
name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -765,15 +1009,339 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Adaptive Content - VA',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/`,
tests: [
{
name: 'isAlphaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'isBetaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
},
},
{
name: 'isAlphaUser & isBetaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toBeVisible();
},
},
],
},
{
name: 'Adaptive Content - Public',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/`,
tests: [
{
name: 'No custom cookie',
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isBetaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser & isBetaUser claims',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
},
},
],
},
{
name: 'Tables',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Default table',
url: 'blocks/tables',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Table with straight corners',
url:
'blocks/tables' +
getCustomizationURL({
styling: {
corners: CustomizationCorners.Straight,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Table with primary color',
url:
'blocks/tables' +
getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
// Test dark mode for each variant
...allThemeModes.flatMap((theme) => [
{
name: `Table in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: `Table with straight corners in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
styling: {
corners: CustomizationCorners.Straight,
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: `Table with primary color in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
]),
],
},
];
for (const testCase of testCases) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, baseURL }) => {
testFn(testEntry.name, async ({ page, baseURL, context }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL);
if (testEntry.cookies) {
await context.addCookies(
testEntry.cookies.map((cookie) => ({
...cookie,
domain: new URL(url).host,
path: '/',
})),
);
}
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
@@ -799,7 +1367,66 @@ for (const testCase of testCases) {
* Create a URL with customization settings.
*/
function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
const encoded = rison.encode_object(partial);
/**
* Default customization settings.
*
* The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults.
*/
const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = {
styling: {
primaryColor: { light: '#346DDB', dark: '#346DDB' },
corners: CustomizationCorners.Rounded,
font: CustomizationFont.Inter,
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
},
},
internationalization: {
locale: CustomizationLocale.En,
},
favicon: {},
header: {
preset: CustomizationHeaderPreset.Default,
links: [],
},
footer: {
groups: [],
},
themes: {
default: CustomizationThemeMode.Light,
toggeable: true,
},
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
aiSearch: {
enabled: true,
},
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
trademark: {
enabled: true,
},
privacyPolicy: {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
+4 -7
View File
@@ -4,6 +4,7 @@ module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
@@ -67,19 +68,15 @@ module.exports = withSentryConfig(
},
},
{
silent: true,
release: process.env.SENTRY_RELEASE,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
authToken: process.env.SENTRY_AUTH_TOKEN,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true,
automaticVercelMonitors: false,
},
);
+20 -18
View File
@@ -1,11 +1,11 @@
{
"name": "gitbook",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
"build": "next build",
"build:cloudflare": "next-on-pages",
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.73.0",
"@gitbook/api": "^0.85.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -25,7 +25,8 @@
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
"@sentry/cloudflare": "8.35.0",
"@sentry/nextjs": "8.35.0",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
@@ -41,18 +42,17 @@
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"memoizee": "^0.4.15",
"next": "14.2.15",
"next": "15.1.3",
"next-themes": "^0.2.1",
"nuqs": "^1.17.4",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "^18",
"react-dom": "^18",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hotkeys-hook": "^4.4.1",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
@@ -60,17 +60,18 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.22.0",
"shiki": "^1.26.1",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0"
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "1.13.7",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.42.1",
"@cloudflare/next-on-pages": "^1.13.5",
"@cloudflare/workers-types": "^4.20240725.0",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
@@ -78,10 +79,12 @@
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/psi": "^4.1.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "18.3.13",
"@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9",
"autoprefixer": "^10",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
"eslint": "^8",
"eslint-config-next": "^14.2.5",
"eslint-plugin-import": "^2.29.1",
@@ -89,8 +92,7 @@
"postcss": "^8",
"psi": "^4.1.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.3",
"env-cmd": "^10.1.0",
"ts-essentials": "^10.0.1"
"ts-essentials": "^10.0.1",
"typescript": "^5.5.3"
}
}
+4 -21
View File
@@ -1,27 +1,10 @@
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
// To tree shake default integrations that we don't use
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false,
Sentry.init({
dsn,
integrations: [],
sampleRate: 0.1,
enableTracing: false,
beforeSendTransaction: () => {
return null;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
});
getCurrentScope().setClient(client);
client.init();
}
-20
View File
@@ -1,20 +0,0 @@
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
beforeSendTransaction: () => {
return null;
},
});
}
-20
View File
@@ -1,20 +0,0 @@
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
beforeSendTransaction: () => {
return null;
},
});
}
@@ -1,11 +1,12 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature,
resizeImage,
CloudflareImageOptions,
checkIsSizableImageURL,
} from '@/lib/images';
} from '@/lib/image-signatures';
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -18,22 +19,21 @@ export const runtime = 'edge';
export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
// The current signature algorithm sets version as 1, but we need to support the older version as well
// for previously generated content. In this case, we default to version 0.
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Prevent infinite loops
if (url.includes('/~gitbook/image')) {
return new Response('Invalid url parameter', { status: 400 });
const signatureVersion = parseSignatureVersion(request.nextUrl.searchParams.get('sv'));
if (!signatureVersion) {
return new Response(`Invalid sv parameter`, { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Check again if the image can be sized, even though we checked when rendering the Image component
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
// Also prevent infinite redirects.
if (!checkIsSizableImageURL(url)) {
return new Response('Invalid url parameter', { status: 400 });
}
@@ -44,6 +44,10 @@ export async function GET(request: NextRequest) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
if (signatureVersion !== CURRENT_SIGNATURE_VERSION) {
return NextResponse.redirect(url, 302);
}
// Cloudflare-specific options are in the cf object.
const options: CloudflareImageOptions = {
fit: 'scale-down',
@@ -88,6 +92,24 @@ export async function GET(request: NextRequest) {
return response;
} catch (error) {
// Redirect to the original image if resizing fails
return Response.redirect(url, 302);
return NextResponse.redirect(url, 302);
}
}
/**
* Parse the image signature version from a query param. Returns null if the version is invalid.
*/
function parseSignatureVersion(input: string | null): SignatureVersion | null {
// Before introducing the sv parameter, all signatures were generated with version 0.
if (!input) {
return '0';
}
// If the query param explicitly asks for a signature version.
if (isSignatureVersion(input)) {
return input;
}
// Otherwise the version is invalid.
return null;
}
@@ -8,10 +8,10 @@ import { useScrollPage } from '@/components/hooks';
/**
* Client component to initialize interactivity for a page.
*/
export function PageClientLayout(props: {}) {
export function PageClientLayout(props: { withSections?: boolean }) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
useScrollPage();
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
useStripFallbackQueryParam();
return null;
@@ -1,11 +1,15 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteLayoutData } from '@/lib/api';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer);
const pointer = await getSiteContentPointer();
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
]);
const language = getSpaceLanguage(customization);
@@ -19,6 +23,9 @@ export default async function NotFound() {
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
{/* Track the page not found as a page view */}
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
</div>
);
}
@@ -5,11 +5,10 @@ import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageClientLayout } from './PageClientLayout';
@@ -21,10 +20,13 @@ export const runtime = 'edge';
* Fetch and render a page.
*/
export default async function Page(props: {
params: PagePathParams;
searchParams: { fallback?: string };
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}) {
const { params, searchParams } = props;
const { params: rawParams, searchParams: rawSearchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const {
content: contentPointer,
@@ -35,6 +37,7 @@ export default async function Page(props: {
customization,
pages,
page,
ancestors,
document,
} = await getPageDataWithFallback({
pagePathParams: params,
@@ -49,12 +52,12 @@ export default async function Page(props: {
if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(absoluteHref(pathname));
redirect(await getAbsoluteHref(pathname));
} else {
notFound();
}
} else if (getPagePath(pages, page) !== rawPathname) {
redirect(pageHref(pages, page, linksContext));
redirect(await getPageHref(pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -73,7 +76,7 @@ export default async function Page(props: {
page,
};
const withSections = Boolean(sections && sections.length > 0);
const withSections = Boolean(sections && sections.list.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return (
@@ -103,6 +106,7 @@ export default async function Page(props: {
customization={customization}
context={contentRefContext}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
@@ -111,14 +115,18 @@ export default async function Page(props: {
/>
</div>
<React.Suspense fallback={null}>
<PageClientLayout />
<PageClientLayout withSections={withSections} />
</React.Suspense>
</>
);
}
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
export async function generateViewport({
params,
}: {
params: Promise<PagePathParams>;
}): Promise<Viewport> {
const { customization } = await fetchPageData(await params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -132,12 +140,12 @@ export async function generateMetadata({
params,
searchParams,
}: {
params: PagePathParams;
searchParams: { fallback?: string };
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
pagePathParams: await params,
searchParams: await searchParams,
});
if (!page) {
@@ -150,16 +158,18 @@ export async function generateMetadata({
.join(' | '),
description: page.description ?? '',
alternates: {
canonical: absoluteHref(getPagePath(pages, page), true),
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
},
openGraph: {
images: [
customization.socialPreview.url ??
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
],
},
robots:
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
(await isSpaceIndexable({ space, site: site ?? null })) &&
isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
};
@@ -183,7 +193,7 @@ async function getPageDataWithFallback(args: {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(pageHref(pages, rootPage?.page));
redirect(await getPageHref(pages, rootPage?.page));
}
page = rootPage?.page;
@@ -1,6 +1,7 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import React from 'react';
import * as ReactDOM from 'react-dom';
@@ -12,7 +13,7 @@ import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { absoluteHref, baseUrl } from '@/lib/links';
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
@@ -28,7 +29,7 @@ export const runtime = 'edge';
export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props;
const nonce = getContentSecurityPolicyNonce();
const nonce = await getContentSecurityPolicyNonce();
const {
content,
space,
@@ -42,7 +43,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
sections,
} = await fetchContentData();
ReactDOM.preconnect(api().endpoint);
const apiCtx = await api();
ReactDOM.preconnect(apiCtx.client.endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
@@ -54,47 +56,51 @@ export default async function ContentLayout(props: { children: React.ReactNode }
});
});
const queryStringTheme = await getQueryStringTheme();
return (
<ClientContexts
nonce={nonce}
forcedTheme={
getQueryStringTheme() ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
<NuqsAdapter>
<ClientContexts
nonce={nonce}
forcedTheme={
queryStringTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
{children}
</SpaceLayout>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
>
{children}
</SpaceLayout>
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
<RocketLoaderDetector nonce={nonce} />
<RocketLoaderDetector nonce={nonce} />
<AdminToolbar space={space} content={content} />
</ClientContexts>
<AdminToolbar space={space} content={content} />
</ClientContexts>
</NuqsAdapter>
);
}
@@ -116,34 +122,34 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()),
metadataBase: new URL(await getBaseUrl()),
icons: {
icon: [
{
url:
customIcon?.light ??
absoluteHref('~gitbook/icon?size=small&theme=light', true),
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url:
customIcon?.dark ??
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
],
},
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
};
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
function getQueryStringTheme() {
const headersList = headers();
async function getQueryStringTheme() {
const headersList = await headers();
const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) {
return null;
@@ -1,8 +1,7 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server';
import { getCollection, getSite, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getSpace, getSite } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -12,7 +11,7 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const [site, space] = await Promise.all([
getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey),
@@ -21,8 +20,8 @@ export async function GET(req: NextRequest) {
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...(isSpaceIndexable({ space, site })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
...((await isSpaceIndexable({ space, site }))
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
const content = lines.join('\n');
@@ -3,7 +3,7 @@ import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
@@ -14,31 +14,33 @@ export const runtime = 'edge';
* Generate a sitemap.xml for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
const urls = pages.map(({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const urls = await Promise.all(
pages.map(async ({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
const lastModified = page.updatedAt || page.createdAt;
return {
url: {
loc: absoluteHref(getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
});
return {
url: {
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
}),
);
const xml = jsontoxml(
[
@@ -3,7 +3,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api';
import { getSite, getSiteData, getSpace } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
@@ -35,12 +35,12 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([
const [space, { customization }] = await Promise.all([
getSpace(spaceId, pointer.siteShareKey),
getCurrentSiteCustomization(pointer),
getSiteData(pointer),
]);
const site = await getSite(pointer.organizationId, pointer.siteId);
const contentTitle = getContentTitle(space, customization, site);
@@ -5,7 +5,7 @@ import { NextRequest } from 'next/server';
import colorContrast from 'postcss-color-contrast/js';
import React from 'react';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
@@ -16,8 +16,8 @@ export const runtime = 'edge';
/**
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, site } = await fetchPageData(params);
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
const { space, page, customization, site } = await fetchPageData(await params);
if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
@@ -51,8 +51,10 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
};
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
const [gridWhite, gridBlack] = await Promise.all([
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
]);
let gridAsset = useLightTheme ? gridBlack : gridWhite;
@@ -90,7 +92,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
break;
}
const favicon = function () {
const favicon = await (async () => {
if ('icon' in customization.favicon)
return (
<img
@@ -107,19 +109,12 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span>
);
return (
<img
src={absoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
)}
alt="Icon"
width={40}
height={40}
tw={tcls('mr-4')}
/>
const src = await getAbsoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
);
};
return <img src={src} alt="Icon" width={40} height={40} tw={tcls('mr-4')} />;
})();
return new ImageResponse(
(
@@ -165,7 +160,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
/>
) : (
<div tw={tcls('flex')}>
{favicon()}
{favicon}
<h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
+7 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { captureException } from '@sentry/nextjs';
import { captureException, withScope } from '@sentry/nextjs';
import React from 'react';
import { Button } from '@/components/primitives/Button';
@@ -15,7 +15,12 @@ export default function ErrorPage(props: {
const language = useLanguage();
React.useEffect(() => {
captureException(error);
withScope((scope) => {
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
}, [error]);
return (
+49 -123
View File
@@ -1,14 +1,12 @@
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api';
import { assert } from 'ts-essentials';
import { RevisionPage } from '@gitbook/api';
import { redirect } from 'next/navigation';
import {
getRevisionPageByPath,
getDocument,
ContentTarget,
getSpaceContentData,
getSiteData,
getSite,
getCurrentSiteCustomization,
getSiteStructure,
getSiteRedirectBySource,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -21,35 +19,18 @@ export interface PageIdParams {
pageId: string;
}
type SectionsList = { list: SiteSection[]; section: SiteSection };
/**
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = getSiteContentPointer();
const content = await getSiteContentPointer();
const [{ space, contentTarget, pages, customization, scripts }, siteStructure] =
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
getSpaceContentData(content, content.siteShareKey),
getSiteData(content),
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]);
const site = siteStructure.site;
const siteSections =
content.siteSectionId && siteStructure.sections
? parseSiteSectionsList(content.siteSectionId, siteStructure.sections)
: null;
const spaces =
siteStructure.spaces ??
(siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []);
// we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
@@ -58,53 +39,39 @@ export async function fetchContentData() {
contentTarget,
space: spaceRelativeToParent ?? space,
pages,
sections: siteSections,
site,
sections,
spaces,
shareKey: content.siteShareKey,
customization,
scripts,
ancestors: [],
};
}
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
const section = sections.find((section) => section.id === siteSectionId);
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
return { list: sections, section } satisfies SectionsList;
}
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {
return typeof section !== 'undefined' && section !== null;
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getSiteContentPointer();
const contentData = await fetchContentData();
const { space, contentTarget, pages, customization, scripts } = await getSiteData(content);
const page = await resolvePage(contentTarget, pages, params);
const [siteStructure, document] = await Promise.all([
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
const page = await resolvePage({
organizationId: contentData.space.organization,
siteId: contentData.site.id,
spaceId: contentData.contentTarget.spaceId,
revisionId: contentData.contentTarget.revisionId,
pages: contentData.pages,
shareKey: contentData.shareKey,
params,
});
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
: null;
return {
content,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...contentData,
...page,
...siteStructure,
document,
};
}
@@ -113,11 +80,17 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
* Resolve a page from the params.
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(
contentTarget: ContentTarget,
pages: RevisionPage[],
params: PagePathParams | PageIdParams,
) {
async function resolvePage(input: {
organizationId: string;
siteId: string;
spaceId: string;
revisionId: string;
shareKey: string | undefined;
pages: RevisionPage[];
params: PagePathParams | PageIdParams;
}) {
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
if ('pageId' in params) {
return resolvePageId(pages, params.pageId);
}
@@ -131,78 +104,31 @@ async function resolvePage(
return page;
}
// If page can't be found, we try with the API, in case we have a redirect
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
//
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
if (rawPathname.length <= 512) {
const resolved = await getRevisionPageByPath(
contentTarget.spaceId,
contentTarget.revisionId,
rawPathname,
);
// If page can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
if (resolved) {
return resolvePageId(pages, resolved.id);
}
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
const resolvedSiteRedirect = await getSiteRedirectBySource({
organizationId,
siteId,
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
siteShareKey: input.shareKey,
});
if (resolvedSiteRedirect) {
return redirect(resolvedSiteRedirect.target);
}
}
return undefined;
}
/**
* Fetch the structure of an organization site.
* This includes the site and its sections or spaces.
*/
async function fetchSiteStructure(args: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([
getSite(organizationId, siteId),
getSiteStructure({ organizationId, siteId, siteShareKey }),
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
]);
const siteSections =
siteStructure.type === 'sections' && siteStructure.structure
? siteStructure.structure
: null;
const siteSpaces =
siteStructure.type === 'siteSpaces' && siteStructure.structure
? parseSpacesFromSiteSpaces(siteStructure.structure)
: null;
// override the title with the customization title
const site = {
...orgSite,
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
};
return {
site,
spaces: siteSpaces,
sections: siteSections,
};
}
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
title: siteSpace.title ?? siteSpace.space.title,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return Object.values(spaces);
}
/**
* Get the page path from the params.
*/
+3 -3
View File
@@ -1,5 +1,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData } from '@/lib/api';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
/**
@@ -9,8 +9,8 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer);
const pointer = await getSiteContentPointer();
const { customization } = await getSiteData(pointer);
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -1,7 +1,7 @@
import { SpaceIntegrationScript } from '@gitbook/api';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData, getSpaceCustomization } from '@/lib/api';
import { getSiteData, getSpaceCustomization } from '@/lib/api';
import { getSiteOrSpacePointerForPDF } from './pointer';
@@ -12,10 +12,10 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteOrSpacePointerForPDF();
const pointer = await getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer
? getSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
? getSiteData(pointer)
: getSpaceLayoutData());
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -25,9 +25,9 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
/**
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData(spaceId: string) {
const [customization, scripts] = await Promise.all([
getSpaceCustomization(spaceId),
async function getSpaceLayoutData() {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(),
[] as SpaceIntegrationScript[],
]);
@@ -11,7 +11,7 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import * as React from 'react';
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
import { DocumentView } from '@/components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server';
@@ -21,9 +21,9 @@ import {
getSpace,
getSpaceCustomization,
getSpaceContentData,
getCurrentSiteCustomization,
getSiteData,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -39,12 +39,10 @@ const DEFAULT_LIMIT = 100;
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF();
const [space, customization] = await Promise.all([
const pointer = await getSiteOrSpacePointerForPDF();
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
]);
return {
@@ -56,21 +54,21 @@ export async function generateMetadata(): Promise<Metadata> {
/**
* Render a space as a standalone HTML page that can be printed as a PDF.
*/
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
const pointer = getSiteOrSpacePointerForPDF();
export default async function PDFHTMLOutput(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const pointer = await getSiteOrSpacePointerForPDF();
const searchParams = new URLSearchParams(props.searchParams);
const searchParams = new URLSearchParams(await props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params
let currentPDFUrl = absoluteHref('~gitbook/pdf', true);
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
currentPDFUrl += '?' + searchParams.toString();
// Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -78,7 +76,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Compute the pages to render
const { pages, total } = selectPages(rootPages, pdfParams);
const pageIds = pages.map(
({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string],
);
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
@@ -90,7 +88,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a
title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? absoluteHref('')}
href={pdfParams.back ?? (await getAbsoluteHref(''))}
className={tcls(
'flex',
'flex-row',
@@ -160,7 +158,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
<React.Suspense
key={page.id}
fallback={
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<p>Loading...</p>
</PrintPage>
}
@@ -205,7 +203,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props;
return (
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<div
className={tcls(
'break-before-page',
@@ -231,10 +229,9 @@ async function PDFPageDocument(props: {
const { space, page, refContext } = props;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
const shouldHighlightCode = createHighlightingContext();
return (
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
@@ -253,8 +250,7 @@ async function PDFPageDocument(props: {
},
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
shouldHighlightCode,
getId: (id) => getPagePDFContainerId(page, id),
}}
/>
) : null}
@@ -8,7 +8,9 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
*
* This function returns the pointer depending on the context.
*/
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
export async function getSiteOrSpacePointerForPDF(): Promise<
SiteContentPointer | SpaceContentPointer
> {
try {
return getSiteContentPointer();
} catch (error) {
@@ -0,0 +1,30 @@
// @ts-ignore
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
import { withSentry } from '@sentry/cloudflare';
import { withMiddlewareHeadersStorage } from './lib/middleware';
const exportedHandler = {
async fetch(request, env, ctx) {
const response = await withMiddlewareHeadersStorage(() =>
nextOnPagesHandler.fetch(request, env, ctx),
);
return response;
},
} satisfies ExportedHandler<{ ASSETS: Fetcher }>;
/**
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
*/
export default withSentry(
(env) => ({
dsn: env.SENTRY_DSN,
release: env.SENTRY_RELEASE,
environment: env.SENTRY_ENVIRONMENT,
tracesSampleRate: 0,
}),
// @ts-ignore
exportedHandler,
);
@@ -44,6 +44,10 @@ export function Ad({
return;
}
if (typeof IntersectionObserver === 'undefined') {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
@@ -8,7 +8,11 @@ import { AdItem } from './types';
/**
* Classic rendering for an ad.
*/
export function AdClassicRendering({ ad }: { ad: AdItem }) {
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
const smallImgSrc =
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
const logoSrc =
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
return (
<a
className={tcls(
@@ -28,25 +32,18 @@ export function AdClassicRendering({ ad }: { ad: AdItem }) {
rel="sponsored noopener"
target="_blank"
>
{'smallImage' in ad ? (
{smallImgSrc && 'smallImage' in ad ? (
<div>
<img
alt="Ads logo"
className={tcls('rounded-md')}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
<img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
</div>
) : (
) : logoSrc && 'logo' in ad ? (
<div
className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }}
>
<img
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
<img alt="Ads logo" src={logoSrc} />
</div>
)}
) : null}
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
@@ -9,8 +9,8 @@ import { AdCover } from './types';
/**
* Cover rendering for an ad.
*/
export function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return (
<a
@@ -40,7 +40,7 @@ interface FetchPlaceholderAdOptions {
export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
@@ -64,7 +64,7 @@ async function fetchAd({
placement,
ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
const { ip, userAgent } = getUserAgentAndIp();
const { ip, userAgent } = await getUserAgentAndIp();
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
@@ -86,8 +86,8 @@ async function fetchAd({
return null;
}
function getPlaceholderAd(): { ad: AdItem; ip: string } {
const { ip } = getUserAgentAndIp();
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
const { ip } = await getUserAgentAndIp();
return {
ad: {
@@ -119,8 +119,8 @@ function getPlaceholderAd(): { ad: AdItem; ip: string } {
};
}
function getUserAgentAndIp() {
const headersSet = headers();
async function getUserAgentAndIp() {
const headersSet = await headers();
const ip =
headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ??
@@ -6,9 +6,10 @@ import * as React from 'react';
import { Button } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
import { tcls } from '@/lib/tailwind';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
/**
* Toast to accept or reject the use of cookies.
*/
@@ -22,10 +22,8 @@ import { Heading } from './Heading';
import { Hint } from './Hint';
import { Images } from './Images';
import { IntegrationBlock } from './Integration';
import { List } from './List';
import { ListItem } from './ListItem';
import { ListOrdered } from './ListOrdered';
import { ListTasks } from './ListTasks';
import { ListUnordered } from './ListUnordered';
import { BlockMath } from './Math';
import { OpenAPI } from './OpenAPI';
import { Paragraph } from './Paragraph';
@@ -65,11 +63,9 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
case 'heading-3':
return <Heading {...props} block={block} />;
case 'list-ordered':
return <ListOrdered {...props} block={block} />;
case 'list-unordered':
return <ListUnordered {...props} block={block} />;
case 'list-tasks':
return <ListTasks {...props} block={block} />;
return <List {...props} block={block} />;
case 'list-item':
return <ListItem {...props} block={block} />;
case 'code':
@@ -1,6 +1,6 @@
import { DocumentBlockContentRef } from '@gitbook/api';
import { Card, Emoji } from '@/components/primitives';
import { Card } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references';
@@ -34,6 +34,10 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
href={resolved.href}
title={resolved.text}
style={style}
insights={{
target: block.data.ref,
position: 'content',
}}
/>
);
}
@@ -48,7 +52,7 @@ async function SpaceRefCard(
return null;
}
const spaceCustomization = await ignoreAPIError(getSpaceCustomization(spaceId));
const { customization: spaceCustomization } = await getSpaceCustomization();
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -71,7 +71,7 @@ export function Caption(
'text-center',
'mt-2',
'text-dark/7',
'dark:text-light/6',
'dark:text-light/7',
)}
>
<Inlines
@@ -15,8 +15,7 @@ import './theme.css';
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId);
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const lines = await highlight(block);
const id = block.key!;
@@ -99,6 +98,8 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
@@ -50,7 +50,6 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => true,
}}
block={block}
ancestorBlocks={[]}
@@ -1,30 +0,0 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Spaces where we'll always use plain highlighting.
*/
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
];
/**
* Protect against memory issues when highlighting a large number of code blocks.
* This context only allows 50 code blocks per render to be highlighted.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
export function createHighlightingContext() {
let count = 0;
return (spaceId?: string | undefined) => {
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
return false;
}
count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
};
}
@@ -5,10 +5,8 @@ import {
createCssVariablesTheme,
HighlighterGeneric,
} from 'shiki/core';
import { loadWasm, createOnigurumaEngine } from 'shiki/engine/oniguruma';
import { bundledLanguages } from 'shiki/langs';
// @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { BundledLanguage, bundledLanguages } from 'shiki/langs';
import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
@@ -50,7 +48,7 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
const lines = highlighter.codeToTokensBase(code, {
lang: langName,
tokenizeMaxLineLength: 120,
tokenizeMaxLineLength: 400,
});
let currentIndex = 0;
@@ -81,18 +79,35 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
});
}
const syntaxAliases: Record<string, BundledLanguage> = {
// "Parser" language does not exist in Shiki, but it's used in GitBook
// The closest language is "Blade"
parser: 'blade',
};
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
return lang in bundledLanguages;
}
/**
* Validate a language name.
*/
function getLanguageForSyntax(syntax: string): keyof typeof bundledLanguages | null {
// @ts-ignore
const lang = bundledLanguages[syntax];
if (!lang) {
return null;
function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
}
// @ts-ignore
return syntax;
// Check if there is a valid alias for the syntax.
const alias = syntaxAliases[syntax];
if (alias && checkIsBundledLanguage(alias)) {
return alias;
}
return null;
}
/**
@@ -299,7 +314,10 @@ function cleanupLine(line: string): string {
const createHighlighter = createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
engine: () => createOnigurumaEngine(import('shiki/wasm')),
engine: () =>
createJavaScriptRegexEngine({
forgiving: true,
}),
});
/**
@@ -308,14 +326,6 @@ const createHighlighter = createdBundledHighlighter<any, any>({
*/
const loadHighlighter = singleton(async () => {
return await trace('highlighting.loadHighlighter', async () => {
if (typeof onigWasm !== 'string') {
// When running bun test, the import is a string, we ignore it and let the module
// loads it on its own.
//
// Otherwise for Vercel/Cloudflare, we need to load it ourselves.
await loadWasm((obj) => WebAssembly.instantiate(onigWasm, obj));
}
const highlighter = await createHighlighter({
themes: [createCssVariablesTheme()],
langs: [],
@@ -1,3 +1,2 @@
export * from './CodeBlock';
export * from './PlainCodeBlock';
export * from './createHighlightingContext';
@@ -38,16 +38,6 @@ export interface DocumentContext {
*/
getId?: (id: string) => string;
/**
* Returns true if the given code block should be highlighted.
* This function was added to protect against memory issues when highlighting
* a large number of code blocks.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
shouldHighlightCode: (spaceId: string | undefined) => boolean;
/**
* True if the blocks should be wrapped in suspense boundary for isolated loading skeletons.
* @default true
@@ -13,7 +13,8 @@ import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props;
const nonce = headers().get('x-nonce') || undefined;
const headersList = await headers();
const nonce = headersList.get('x-nonce') || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
import { Link } from '../primitives';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props;
@@ -21,9 +22,13 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return (
<Caption {...props} wrapperStyle={[]}>
<a
<Link
href={file.downloadURL}
download={file.name}
insights={{
target: block.data.ref,
position: 'content',
}}
className={tcls(
'group/file',
'flex',
@@ -77,7 +82,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
{contentType}
</div>
</div>
</a>
</Link>
</Caption>
);
}
@@ -16,8 +16,8 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
return (
<div
className={tcls(
'px-4',
'py-4',
'hint',
'p-4',
'transition-colors',
'rounded-md',
'straight-corners:rounded-none',
@@ -26,19 +26,16 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
)}
>
<div className={tcls('flex', 'flex-row')}>
<div
<Icon
icon={hintStyle.icon}
className={tcls(
'flex',
'items-start',
'justify-center',
'pr-3',
'size-5',
'mr-4',
'mt-0.5',
firstLine.lineHeight,
hintStyle.iconColor,
)}
>
<Icon icon={hintStyle.icon} className={tcls('size-4')} />
</div>
/>
<Blocks
{...contextProps}
ancestorBlocks={[...ancestorBlocks, block]}
@@ -48,7 +45,7 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
// render hash icon on the other side of the heading
'flip-heading-hash',
)}
style={['flex-1', 'space-y-4', '[&>p]:text-sm', '[&>p]:leading-relaxed']}
style={['flex-1', 'space-y-4', '[&_.hint]:border', '[&_pre]:border']}
/>
</div>
</div>
@@ -65,130 +62,69 @@ const HINT_STYLES: {
} = {
info: {
icon: 'circle-info',
iconColor: ['text-periwinkle-700', 'dark:text-periwinkle-400'],
iconColor: ['text-primary-500', 'dark:text-primary-400'],
bodyColor: [
'[&>a]:text-periwinkle-700',
'[&>a:hover]:text-periwinkle-800',
'[&>code]:bg-periwinkle-700/4',
'[&>code]:text-inherit',
'[&>code]:shadow-none',
'text-periwinkle-900',
'dark:text-periwinkle-200',
'dark:[&>a]:text-periwinkle',
'dark:[&>a:hover]:text-periwinkle-600',
'dark:[&>code]:bg-periwinkle-200/2',
'dark:[&>code]:text-inherit',
'decoration-periwinkle-700/6',
'dark:decoration-periwinkle/6',
'[&_a]:text-primary-500',
'[&_a:hover]:text-primary-600',
'dark:[&_a]:text-primary-400',
'dark:[&_a:hover]:text-primary-300',
],
style: [
'bg-gradient-to-b',
'from-periwinkle/6',
'to-periwinkle/5',
'ring-1',
'ring-inset',
'ring-dark/1',
'dark:ring-periwinkle/1',
'dark:from-periwinkle/2',
'dark:to-periwinkle/[0.1]',
'bg-dark-1/1',
'border-dark/3',
'dark:bg-light/1',
'dark:border-light/3',
'[&_.can-override-bg]:bg-dark-1/2',
'[&_.can-override-text]:text-dark',
'dark:[&_.can-override-bg]:bg-light/2',
'dark:[&_.can-override-text]:text-light',
],
},
warning: {
icon: 'circle-exclamation',
iconColor: ['text-yellow-800', 'dark:text-yellow'],
iconColor: ['text-amber-500', 'dark:text-orange-400'], // Darker shades of orange-* mismatch with lighter shades, so in light mode we use amber text on top of orange bg.
bodyColor: [
'[&>a]:text-yellow-700',
'[&>a:hover]:text-yellow-800',
'[&>code]:bg-yellow-600/5',
'[&>code]:text-inherit',
'[&>code]:shadow-none',
'text-yellow-900',
'dark:text-yellow-200',
'dark:[&>a]:text-yellow',
'dark:[&>a:hover]:text-yellow-600',
'dark:[&>code]:bg-yellow-200/2',
'dark:[&>code]:text-inherit',
'decoration-yellow-700/6',
'dark:decoration-yellow/6',
],
style: [
'bg-gradient-to-b',
'from-yellow/6',
'to-yellow/5',
'ring-1',
'ring-inset',
'ring-dark/1',
'dark:ring-yellow/[0.02]',
'dark:from-yellow/[0.06]',
'dark:to-yellow/2',
'[&_a]:text-orange-800',
'[&_a:hover]:text-orange-900',
'dark:[&_a]:text-orange-400',
'dark:[&_a:hover]:text-orange-300',
'[&_.can-override-bg]:bg-orange-500/3',
'[&_.can-override-text]:text-orange-800',
'dark:[&_.can-override-text]:text-orange-400',
'decoration-orange-800/6',
'dark:decoration-orange-400/6',
],
style: ['bg-orange-500/2', 'border-orange-500/4'],
},
danger: {
icon: 'triangle-exclamation',
iconColor: ['text-pomegranate-700', 'dark:text-pomegranate-400'],
iconColor: ['text-red-500', 'dark:text-red-400'],
bodyColor: [
'[&>a]:text-pomegranate-600/9',
'[&>a:hover]:text-pomegranate-800',
'[&>code]:bg-pomegranate-600/4',
'[&>code]:text-inherit',
'[&>code]:shadow-none',
'text-pomegranate-900',
'dark:text-pomegranate-100',
'dark:[&>a]:text-pomegranate',
'dark:[&>a:hover]:text-pomegranate-600',
'dark:[&>code]:bg-pomegranate-200/2',
'dark:[&>code]:text-inherit',
'decoration-pomegranate-600/6',
'dark:decoration-pomegranate/6',
],
style: [
'bg-gradient-to-b',
'from-pomegranate/4',
'to-pomegranate/3',
'ring-1',
'ring-inset',
'ring-dark/1',
'dark:ring-pomegranate/1',
'dark:from-pomegranate/2',
'dark:to-pomegranate/3',
'[&_a]:text-red-800',
'[&_a:hover]:text-red-900',
'dark:[&_a]:text-red-400',
'dark:[&_a:hover]:text-red-300',
'[&_.can-override-bg]:bg-red-500/3',
'[&_.can-override-text]:text-red-400',
'decoration-red-800/6',
'dark:decoration-red-400/6',
],
style: ['bg-red-500/2', 'border-red-500/4'],
},
success: {
icon: 'circle-check',
iconColor: ['text-teal-700', 'dark:text-teal-400'],
iconColor: ['text-green-500', 'dark:text-green-400'],
bodyColor: [
'[&>a]:text-teal-600',
'[&>a:hover]:text-teal-800',
'[&>code]:bg-teal-600/4',
'[&>code]:text-inherit',
'[&>code]:shadow-none',
'text-teal-800',
'dark:text-teal-100',
'dark:[&>a]:text-teal-400',
'dark:[&>a:hover]:text-teal-500',
'dark:[&>code]:bg-teal-200/2',
'dark:[&>code]:text-inherit',
'decoration-teal/6',
'dark:decoration-teal/6',
],
style: [
'bg-gradient-to-b',
'from-teal/4',
'to-teal/3',
'ring-1',
'ring-inset',
'ring-dark/1',
'dark:ring-teal/1',
'dark:from-teal/2',
'dark:to-teal/3',
'[&_a]:text-green-800',
'[&_a:hover]:text-green-900',
'dark:[&_a]:text-green-400',
'dark:[&_a:hover]:text-green-300',
'[&_.can-override-bg]:bg-green-500/3',
'[&_.can-override-text]:text-green-800',
'dark:[&_.can-override-text]:text-green-400',
'decoration-green-800/6',
'dark:decoration-green-400/6',
],
style: ['bg-green-500/2', 'border-green-500/4'],
},
};
@@ -22,13 +22,18 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
}
const isInLink = ancestorInlines.some((ancestor) => ancestor.type === 'link');
const sizes = await getImageSizes(size, src);
return (
/* Ensure images dont expand to the size of the container where this Image may be nested in. Now it's always nested in a size-restricted container */
<span className={tcls(size !== 'line' ? ['inline-flex', 'max-w-[300px]'] : null)}>
<span
className={tcls(
size !== 'line' ? ['inline-flex', 'max-w-[300px]', 'align-middle'] : null,
)}
>
<Image
alt={inline.data.caption ?? ''}
sizes={await getImageSizes(size, src)}
sizes={sizes}
sources={{
light: {
src: src.href,
@@ -25,7 +25,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
return (
<Link
href={resolved.href}
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors "
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors"
insights={{
target: inline.data.ref,
position: 'content',
}}
>
<Inlines
context={context}
@@ -0,0 +1,44 @@
import {
DocumentBlockListOrdered,
DocumentBlockListTasks,
DocumentBlockListUnordered,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function List(
props: BlockProps<
DocumentBlockListUnordered | DocumentBlockListOrdered | DocumentBlockListTasks
>,
) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
tag={getListTag(block.type)}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={['space-y-2', style]}
/>
);
}
function getListTag(
type:
| DocumentBlockListUnordered['type']
| DocumentBlockListOrdered['type']
| DocumentBlockListTasks['type'],
) {
switch (type) {
case 'list-ordered':
return 'ol';
case 'list-unordered':
case 'list-tasks':
return 'ul';
default:
assertNever(type);
}
}
@@ -1,13 +0,0 @@
.olListItemBullet {
/* Align the bullet container with the first line of text */
line-height: inherit;
height: 1lh;
/* Align the bullet content with the middle of the text */
display: flex;
align-items: center;
}
.olListItemBullet::before {
@apply text-base;
--tw-content: attr(data-value) '. ';
content: var(--tw-content);
}
@@ -1,114 +1,233 @@
import {
DocumentBlock,
DocumentBlockListItem,
DocumentBlockListOrdered,
DocumentBlockListTasks,
DocumentBlockListUnordered,
} from '@gitbook/api';
import classNames from 'classnames';
import assertNever from 'assert-never';
import { assert } from 'ts-essentials';
import { Checkbox } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
import styles from './ListItem.module.css';
import { getBlockTextStyle } from './spacing';
export function ListItem(props: BlockProps<DocumentBlockListItem>) {
const { block, ancestorBlocks, ...contextProps } = props;
const textStyle = getBlockTextStyle(block);
const parent = ancestorBlocks[ancestorBlocks.length - 1];
assert(
(parent && parent.type === 'list-ordered') ||
parent.type === 'list-unordered' ||
parent.type === 'list-tasks',
'Invalid parent list type',
);
const parent = ancestorBlocks[ancestorBlocks.length - 1] as
| DocumentBlockListOrdered
| DocumentBlockListUnordered
| DocumentBlockListTasks
| undefined;
const blocksElement = (
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls(
'min-h-[1lh]',
// flip heading hash icon if list item is a heading
'flip-heading-hash',
// remove margin-top for the first heading in a list
'[&:is(h2)>div]:mt-0',
'[&:is(h3)>div]:mt-0',
'[&:is(h4)>div]:mt-0',
// Override the "mx-auto" class from UnwrappedBlocks
'mx-0',
)}
style="space-y-2 flex flex-col flex-1"
/>
);
const ListItemType = () => {
switch (parent?.type) {
case 'list-tasks':
return (
<li>
<div className={tcls('flex', 'flex-row')}>
<div
className={tcls(
textStyle.textSize,
'flex',
'flex-col',
'justify-center',
'h-[1lh]',
)}
>
<Checkbox
id={block.key!}
disabled
checked={block.data?.checked}
className={tcls('relative')}
/>
</div>
<label htmlFor={block.key} className={tcls('flex-1')}>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls('flip-heading-hash')}
style={tcls('ml-2', 'space-y-2')}
/>
</label>
</div>
</li>
);
case 'list-ordered':
const start = parent.data.start ?? 1;
const indexInParent = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const index = indexInParent + start;
return (
<li value={index} className={tcls(textStyle.lineHeight)}>
<div
data-value={index}
className={classNames(
'bullet',
styles.olListItemBullet,
tcls(textStyle.textSize),
)}
></div>
{/* zero width space to force layouts with empty lists */}
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={tcls('space-y-2', 'flex-1', 'flex', 'flex-col')}
blockStyle={tcls(
textStyle.lineHeight,
'min-h-[1lh]',
//flip heading hash icon if list item is a heading
'flip-heading-hash',
)}
switch (parent.type) {
case 'list-tasks':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<Checkbox
id={block.key!}
disabled
checked={block.data?.checked}
className="relative"
size="small"
/>
</li>
);
default:
return (
<li className={tcls(textStyle.lineHeight)}>
<div className={tcls('bullet', textStyle.textSize)}></div>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={tcls('space-y-2', 'flex', 'flex-col')}
blockStyle={tcls(
'min-h-[1lh]',
//flip heading hash icon if list item is a heading
'flip-heading-hash',
)}
/>
</li>
);
}
};
</ListItemPrefix>
return <ListItemType />;
<label htmlFor={block.key} className={tcls('flex-1')}>
{blocksElement}
</label>
</ListItemLI>
);
case 'list-ordered':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<PseudoBefore
content={getOrderedListItemPrefixContent({
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
block,
parent,
})}
style={{
fontSize: 'min(1em, 24px)',
}}
/>
</ListItemPrefix>
{blocksElement}
</ListItemLI>
);
case 'list-unordered':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<PseudoBefore
content={getUnorderedListItemsPrefixContent({
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
})}
fontFamily="Arial"
style={{ fontSize: 'min(1.5em, 24px)', lineHeight: 1 }}
/>
</ListItemPrefix>
{blocksElement}
</ListItemLI>
);
default:
assertNever(parent);
}
}
function getListItemDepth(input: {
ancestorBlocks: DocumentBlock[];
type: DocumentBlockListOrdered['type'] | DocumentBlockListUnordered['type'];
}): number {
const { ancestorBlocks, type } = input;
let depth = -1;
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
const block = ancestorBlocks[i];
if (block.type === type) {
depth = depth + 1;
continue;
}
if (block.type === 'list-item') {
continue;
}
break;
}
return depth;
}
function ListItemLI(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
const textStyle = getBlockTextStyle(props.block);
return <li className={tcls(textStyle.lineHeight, 'flex items-start')}>{props.children}</li>;
}
function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
const textStyle = getBlockTextStyle(props.block);
return (
<div
className={tcls(
textStyle.textSize,
textStyle.lineHeight,
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark-4 dark:text-light-4',
)}
>
{props.children}
</div>
);
}
function getUnorderedListItemsPrefixContent(input: { depth: number }): string {
switch (input.depth % 3) {
case 0:
return '•';
case 1:
return '◦';
case 2:
return '▪';
default:
return '•';
}
}
function PseudoBefore(props: {
style?: React.CSSProperties;
content: string;
fontFamily?: string;
}) {
return (
<div
className="before:font-var before:content-[--pseudoBefore--content]"
style={
{
'--pseudoBefore--content': `'${props.content}'`,
'--font-family': props.fontFamily ?? 'inherit',
...props.style,
} as React.CSSProperties
}
/>
);
}
function getOrderedListItemPrefixContent(input: {
depth: number;
parent: DocumentBlockListOrdered;
block: DocumentBlockListItem;
}): string {
const { parent, block } = input;
const start = parent.data.start ?? 1;
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const value = index + start;
switch (input.depth % 3) {
// Use numbers
case 0: {
return `${value}.`;
}
// Use letters
case 1: {
const letters = 'abcdefghijklmnopqrstuvwxyz';
return `${letters[(value - 1) % letters.length]}.`;
}
// Use roman numbers
case 2: {
return `${toRoman(value).toLowerCase()}.`;
}
default:
return '•';
}
}
function toRoman(input: number): string {
const lookup = {
M: 1000,
CM: 900,
D: 500,
CD: 400,
C: 100,
XC: 90,
L: 50,
XL: 40,
X: 10,
IX: 9,
V: 5,
IV: 4,
I: 1,
};
let roman = '';
let number = input;
for (const i in lookup) {
while (number >= lookup[i as keyof typeof lookup]) {
roman += i;
number -= lookup[i as keyof typeof lookup];
}
}
return roman;
}
@@ -1,45 +0,0 @@
import { DocumentBlockListOrdered } from '@gitbook/api';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function ListOrdered(props: BlockProps<DocumentBlockListOrdered>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
tag="ol"
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[
'space-y-2',
'flex',
'flex-col',
'[&>li]:gap-[1ch]',
'[counter-reset:list-decimal]',
'[&>li]:flex',
'[&>li]:flex-row',
/* '[&>li>.bullet]:w-[1ch]', */
'[&>li>.bullet]:tabular-nums',
'[&>li>.bullet]:whitespace-nowrap',
'[&>li>.bullet]:list-decimal',
'[&>li>.bullet]:before:h-[1lh]',
'[&>li>.bullet]:before:leading-[inherit]',
'[&>li>.bullet]:before:flex',
/* '[&>li>.bullet]:before:pr-[1ch]', */
'[&>li>.bullet]:text-dark/6',
//remove any spacing when using heading as list item
'[&>li>div_div]:mt-0',
'dark:[&>li>.bullet]:text-light/6',
style,
]}
/>
);
}

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