Compare commits

..

179 Commits

Author SHA1 Message Date
Greg Bergé b29a885bae Fix NPM_TOKEN used to publish packages (#2844) 2025-02-18 10:25:55 +01:00
Samy Pessé 919da7f2c9 Version Packages (#2839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-18 00:44:59 +01:00
Greg Bergé d222c116e0 Fix avoid showing child attributes if empty (#2842) 2025-02-17 17:12:26 +01:00
Greg Bergé df840dac18 Avoid parsing example description (#2841) 2025-02-17 17:12:16 +01:00
Nolann B. f8d4c7697c Fix schema validation error (#2820) 2025-02-17 15:11:58 +01:00
Nolann B. dddb4ecbb8 Fix OpenAPI long tab group description (#2838) 2025-02-17 12:51:13 +01:00
Greg Bergé 470d10cb47 Mark GitBook v2 as private (#2837) 2025-02-17 09:41:20 +01:00
Samy Pessé dae11e9607 Version Packages (#2644)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-17 09:12:54 +01:00
Greg Bergé 3d1eb53708 Run visual testing on customers docs (#2833)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2025-02-14 17:00:48 +01:00
Zeno Kapitein a76c9a56a7 Flip card logic (#2835) 2025-02-14 15:34:03 +01:00
Greg Bergé eaad0d4e09 Fix visual testing logo flakiness (#2834) 2025-02-14 12:16:45 +01:00
Greg Bergé 46edde9da6 Improve OpenAPI abstraction to be more flexible on usage (#2832) 2025-02-14 10:41:09 +01:00
Nolann B. dda0cc635e Flatten OpenAPI security object (#2818) 2025-02-13 16:44:06 +01:00
Greg Bergé 727bde2d16 Refactor and optimize OpenAPI parsing (#2830) 2025-02-13 16:30:34 +01:00
Zeno Kapitein 9f30af1b33 Fix card layout for cards without cover (#2831) 2025-02-13 15:13:03 +01:00
Zeno Kapitein ff05e20537 Improvements to inline images in headings (#2829) 2025-02-13 13:44:16 +01:00
Scott Cazan 53de5b13c1 Fix URL resolution in Site Sections for Ask (#2827) 2025-02-13 09:22:11 +01:00
Zeno Kapitein a025118f69 Change card layout depending on cover aspect ratio (#2828) 2025-02-12 17:21:52 +01:00
Zeno Kapitein 71688a817b Implement new themes (#2822)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-02-12 15:37:41 +01:00
Zeno Kapitein 00c476ea7f Fix typo in sticky page outline (#2826) 2025-02-12 09:13:45 +01:00
Samy Pessé 5576906914 Fix table of content displaying arrow next to page with only hidden pages (#2825) 2025-02-10 16:24:27 +01:00
Samy Pessé d5aaccd2c4 Remove use of deprecated API createSitesPageFeedback (#2824) 2025-02-10 13:20:33 +01:00
Samy Pessé 7059c2ba25 Fetch computed document (#2823) 2025-02-07 23:09:16 +01:00
Brett Jephson aaab157d49 Section groups visual bug fix (#2821) 2025-02-07 15:53:51 +00:00
Greg Bergé 9943276c0c Revert PR with bugs (#2819) 2025-02-07 15:08:58 +01:00
Nolann B. c31cf5243a Sync tabs across all OpenAPI blocks (#2817) 2025-02-07 13:15:06 +01:00
Nolann B. 0c03676efb Improve OpenAPI Securities sample and headers (#2816) 2025-02-07 12:35:46 +01:00
Nolann B. 883d10acf8 Support for OpenAPI references (#2813) 2025-02-07 11:46:23 +01:00
Nolann B. 3e5e458118 Handle isArray schema type (#2815) 2025-02-07 11:23:36 +01:00
Brett Jephson 0510b6f29e Add section description (#2814) 2025-02-06 16:53:48 +00:00
Zeno Kapitein 92b7668bf3 Fix header offset (#2812) 2025-02-06 15:12:07 +01:00
Scott Cazan 3973b1cbf3 Handle unresolved references in OpenAPI response' (#2811)
Co-authored-by: Steven H <steven@gitbook.io>
2025-02-06 11:25:42 +01:00
Nolann B. 160fca1ea1 Update OpenAPI design (#2787) 2025-02-06 10:47:23 +01:00
Zeno Kapitein e90c96f7e9 Page outline positioning (#2805)
Co-authored-by: Claire Chabas <claire.chabas@gmail.com>
2025-02-05 17:08:30 +01:00
Scott Cazan d935fb1b2e Only push scrolling down when Footer is present (#2807) 2025-02-05 12:26:31 +01:00
Scott Cazan a6529584a5 Handle Errors from Unresolved Refs (#2810) 2025-02-05 10:52:35 +00:00
Zeno Kapitein 18953b2d6b Make tint color subtler when equal to primary color (#2808)
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2025-02-05 11:40:19 +01:00
Zeno Kapitein 21cbd9e3b0 Change link color to primary subtle (#2809) 2025-02-05 09:02:53 +01:00
Brett Jephson 38061bdf65 RND-5646: section groups navigation menu (#2789) 2025-02-04 17:07:57 +00:00
Nolann B. d9029c7e1e Include apiKey example in CodeSample security headers (#2804) 2025-02-04 17:19:39 +01:00
Steven H fe8acc986e Fix an issue where a missing OpenAPI example would crash the page. (#2806) 2025-02-04 11:48:38 +00:00
Nolann B. e721f17f7e Implement Scalar's getExampleFromSchema (#2802) 2025-02-04 11:59:47 +01:00
Nolann B. 2f73db785e Support non primitive examples in OpenAPI block (#2775) 2025-02-03 17:07:29 +01:00
Zeno Kapitein 9e18ae6587 Overhaul colour system & classes (#2783) 2025-02-03 12:23:19 +01:00
Samy Pessé 9eca010f94 Improve display of recommended questions using streaming (#2800) 2025-01-31 23:13:40 +01:00
Greg Bergé 6e54a06d9c Support response examples in API blocks (#2799) 2025-01-31 18:47:29 +01:00
Greg Bergé 1f8e416e41 Highlight code client-side (#2797) 2025-01-31 13:47:37 +01:00
Samy Pessé cfccc44406 Setup package and deployment for v2 (#2791) 2025-01-31 12:28:41 +01:00
Claire Chabas 45b30c9c27 Fix icons generate script (#2798) 2025-01-31 11:05:38 +01:00
Samy Pessé 67f78368fb Revert "Highlight code client-side" (#2796) 2025-01-30 20:09:57 +01:00
Greg Bergé d9c8d57e8e Avoid dereferencing before caching (#2793) 2025-01-30 17:15:19 +01:00
Greg Bergé 0d615e3888 Highlight code client-side (#2786) 2025-01-30 16:08:39 +01:00
Greg Bergé 68287d3df4 Cache API spec for 24 hours, revalidated every 2 hours (#2792) 2025-01-30 15:16:45 +01:00
Greg Bergé dff08ae3e3 Load Scalar only when the button is pressed (#2790) 2025-01-30 15:01:59 +01:00
Zeno Kapitein 8cfa67c1dd Fix default outline list styling (#2788) 2025-01-30 13:56:43 +01:00
Greg Bergé b41d425993 Improve OpenAPI performances (#2784) 2025-01-30 11:13:01 +01:00
Greg Bergé 12c7862250 Use Scalar OpenAPI parser (#2780) 2025-01-30 08:44:08 +01:00
Greg Bergé 6f54826296 Replace highlight colors (#2781) 2025-01-29 15:00:01 +01:00
Steven H ccf2cffc4b Hotfix docs.radiantdrift.com crashing in production. (#2782) 2025-01-29 12:42:01 +00:00
Zeno Kapitein f7b801b4c8 Support user comments after rating (#2771)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-01-28 09:39:32 +01:00
Samy Pessé 95f2aa45eb Integration with new site insights (#2777) 2025-01-28 09:18:50 +01:00
Samy Pessé 9524ff5f5a Migrate to bun text-based lockfile (#2770) 2025-01-28 08:47:40 +01:00
Steven H 1823101b03 Fix internal properties appearing in OpenAPI docs. (#2774) 2025-01-23 21:35:06 +00:00
Samy Pessé 1a8cfd2a8b Hide toolbar when previewing (#2773) 2025-01-23 21:58:02 +01:00
Greg Bergé 5b4e710770 Support llms.txt (#2768) 2025-01-23 16:37:45 +01:00
Zeno Kapitein 1762f85ef9 Reduce gap between subsequent header buttons (#2765) 2025-01-23 14:10:47 +01:00
Samy Pessé d370a3f0e3 Align the revisions/changes path for multi-id with the normal one (#2769) 2025-01-23 10:00:11 +01:00
Greg Bergé d3e573c2e9 Generate sitemap for all sections and spaces (#2767) 2025-01-22 16:57:57 +01:00
Zeno Kapitein 94876e3ad1 Fix regression issue with page icons for multi-line titles (#2764) 2025-01-21 17:03:33 +01:00
Zeno Kapitein ad190602c7 Tweak card background, better margins for card headings (#2763) 2025-01-21 17:03:22 +01:00
Scott Cazan 162b4b78b6 Add HTTP examples to API blocks (#2759) 2025-01-21 15:34:42 +01:00
Zeno Kapitein e5dc05e994 Restyle footer, allow >4 link groups (#2761) 2025-01-21 14:50:33 +01:00
Samy Pessé b46cff70c0 Fix insights prop passed to Link (#2760) 2025-01-21 07:52:00 +01:00
Greg Bergé b6c3870eda Add support for keyboard marks (#2758) 2025-01-20 12:06:38 +01:00
Taran Vohra 1c1f504601 Fix Image zoom e2e test (#2757) 2025-01-20 16:13:23 +05:30
Taran Vohra 1b8a45678c Fix image blocks zoomable behaviour (#2756) 2025-01-20 13:22:12 +05:30
Viktor Renkema 38de9eb9d7 Restore borders on embed blocks with Captions (#2755) 2025-01-17 12:50:23 +01:00
Greg Bergé eac1314a7d Lazy load iframely script (#2753) 2025-01-17 08:28:38 +01:00
Greg Bergé 29d59793dc Disable C/C++ highlight temporarily (#2754) 2025-01-17 08:28:10 +01:00
Greg Bergé 128ad20280 Ignore cache invalidation error from local backend (#2752) 2025-01-15 14:13:10 +01:00
Viktor Renkema 994ebff8c1 Remove borders on Caption wrapper (#2742) 2025-01-15 12:05:13 +01:00
Greg Bergé 142938401c Fix error when accessing some not found pages (#2751) 2025-01-15 12:04:27 +01:00
Greg Bergé 65cc4afb23 Fix error when accessing a change request not found (#2723) 2025-01-14 19:52:26 +01:00
Greg Bergé e000f23c44 Fix visual testing flakiness (#2750) 2025-01-14 18:03:29 +01:00
Claire Chabas 1de9d1aa33 Antialias text but not code/pre elements (#2747) 2025-01-14 15:48:05 +01:00
Zeno Kapitein 0e601e277b Header button styling (#2748) 2025-01-14 15:20:26 +01:00
Greg Bergé e582a47f37 Remove screenshot from VA test (#2749) 2025-01-14 15:05:04 +01:00
Greg Bergé 1b251a78b0 Fix visitor authentication end-to-end tests (#2746) 2025-01-14 14:42:58 +01:00
Zeno Kapitein 24f5249ae9 Fix vertical section overflow (#2744) 2025-01-14 12:49:20 +01:00
Greg Bergé 17c1631013 Visual testing stabilization (#2741) 2025-01-14 12:39:12 +01:00
Greg Bergé cb100d5e00 Allow only valid values for theme query string (#2745) 2025-01-14 12:33:59 +01:00
Greg Bergé 1fcc807513 Fix customization not found (#2734) 2025-01-14 12:09:43 +01:00
Zeno Kapitein 5664e5a30f Fix variant dropdown styling in header (#2730) 2025-01-14 12:09:04 +01:00
Taran Vohra 26e64019d8 Remove KV cache backend and only use DO cache (#2743) 2025-01-14 16:29:48 +05:30
Zeno Kapitein 56331d2e74 Fix breadcrumbs emoji display + add contrast styles (#2735) 2025-01-14 11:46:26 +01:00
Greg Bergé 55b095e503 Wait for icons to be loaded before screenshot (#2740) 2025-01-14 10:35:44 +01:00
Scott Cazan 6a073e1dd5 Enable anti-aliasing for published docs (#2736) 2025-01-14 10:13:40 +01:00
Viktor Renkema 960e25a3f6 Apply vertical alignment to Tables as configured in-app (#2627) 2025-01-14 09:45:15 +01:00
Greg Bergé 1de338c312 Remove useless animation on section tabs (#2739) 2025-01-14 08:57:08 +01:00
Steven H dbba50cea4 Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site. (#2738) 2025-01-13 21:31:40 +00:00
Zeno Kapitein 8f046a9cd6 Start using tint in more places: TOC and PageAside (#2733) 2025-01-13 15:00:19 +01:00
Greg Bergé 2906e6038a Downgrade to Next.js v14 (#2732) 2025-01-13 14:50:31 +01:00
Greg Bergé 87b8ea8c05 Revert reading a context from headers and passing it (#2727) 2025-01-13 12:44:00 +01:00
Greg Bergé 47971dce31 Fix OG image generation (#2729) 2025-01-13 12:20:24 +01:00
Greg Bergé 528a05398c Fix server actions stability (#2728)
Co-authored-by: Steven Hall <steven@gitbook.io>
2025-01-13 10:04:14 +00:00
Taran Vohra f584148ab9 temporarily add back cloudflare KV cache backend (#2726) 2025-01-12 12:57:38 +05:30
Zeno Kapitein 0de22f7a96 Fix padding of ads box (#2724) 2025-01-10 21:49:54 +01:00
Greg Bergé 09c7c30779 Try to fix error on og image generation (#2722) 2025-01-10 21:38:25 +01:00
Greg Bergé fde32e26bf Force route handler to be dynamic to avoid errors (#2721) 2025-01-10 16:26:25 +01:00
Greg Bergé a6f65916f1 Separate cache for HTTP methods (#2720) 2025-01-10 16:09:35 +01:00
Claire Chabas 6059efe094 Fix no results showing while there are results (#2719) 2025-01-10 16:08:45 +01:00
Zeno Kapitein 16194c50ba Vertical orientation for sections list on sites without header (#2714) 2025-01-10 14:40:10 +01:00
Steven H af3c6a971c Reintroduce a safety check around search whilst we continue investigating caching. (#2718)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2025-01-10 13:32:44 +00:00
Greg Bergé ecfdb976a3 Cleanup headers usage (#2717) 2025-01-10 14:15:39 +01:00
Steven H 08acea651a Log an exception if an empty cache object is returned (#2716)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2025-01-10 09:25:40 +00:00
Greg Bergé 6b503608cd Fix view transition error on Safari (#2715) 2025-01-10 07:27:49 +01:00
Greg Bergé d8763997a5 Fix UI search with no ask (#2713) 2025-01-09 17:22:06 +01:00
Greg Bergé 32aa1f99c1 Handle security issue with cookies on Safari (#2711) 2025-01-09 17:04:26 +01:00
Valentino Hudhra 718a8a54b1 Position the variant picker in the ToC (#2632)
Co-authored-by: Zeno Kapitein <admin@zeno.zone>
Co-authored-by: Zeno Kapitein <zenomilan@me.com>
2025-01-09 16:47:20 +01:00
Zeno Kapitein 741dd490d4 Bump heading-3 font size to differentiate it from paragraphs (#2706) 2025-01-09 16:43:04 +01:00
Greg Bergé 02d876e6d8 Fix search UI behaviour (#2712) 2025-01-09 16:27:04 +01:00
Greg Bergé 56c52e0bb0 Handle Firefox security error on localStorage (#2710) 2025-01-09 15:36:08 +01:00
Greg Bergé 6036becf93 Fix header menu scroll ref logic (#2708) 2025-01-09 14:22:34 +01:00
Greg Bergé 3a7210d514 Fix zoom image view transition on Safari (#2709) 2025-01-09 14:08:45 +01:00
Greg Bergé 6088fa582d Simplify search results logic (#2707) 2025-01-09 13:53:21 +01:00
Greg Bergé 6691492ef7 Fix viewing pdf from space (#2705) 2025-01-09 12:28:48 +01:00
Zeno Kapitein 7ee9158cd3 Restyle PageAside to use sidebar list styles (#2680)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2025-01-09 11:21:30 +01:00
Claire Chabas 5ae1b883ab Fix shrinking page icons (#2702) 2025-01-08 16:59:32 +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
309 changed files with 74834 additions and 6355 deletions
+1
View File
@@ -0,0 +1 @@
1.2.1
+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'
+191 -46
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,14 +18,13 @@ jobs:
checks: write
statuses: write
outputs:
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-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:
@@ -40,12 +39,14 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages
run: bun run build:cloudflare
run: bun run turbo build:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
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.11.0
@@ -74,26 +75,159 @@ jobs:
-H "Accept: application/vnd.github.v3+json" \
-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 }}
deploy-v2-vercel:
name: Deploy v2 to Vercel
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "VERCEL_ENVIRONMENT=production" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "VERCEL_ENVIRONMENT=preview" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Pull Vercel Environment Information
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: bun run vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
run: |
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build worker
run: bun run turbo build:v2:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
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.11.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.82.0'
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy
- deploy-v2-vercel
- deploy-v2-cloudflare
steps:
- name: Find GitHub Comment
uses: peter-evans/find-comment@v3
id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: GitBook Preview
body-includes: 'Summary of the deployments'
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
**GitBook Preview**
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 }})
Summary of the deployments:
### Version 1 (production)
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
@@ -102,10 +236,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
- name: Setup Playwright
@@ -113,7 +245,7 @@ jobs:
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
@@ -121,6 +253,31 @@ jobs:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
visual-testing-customers:
runs-on: ubuntu-latest
name: Visual Testing Customers
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers'
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results-customers
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
@@ -128,10 +285,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:
@@ -139,7 +294,7 @@ jobs:
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
@@ -147,10 +302,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:
@@ -162,10 +315,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:
@@ -177,10 +328,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:
@@ -191,16 +340,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
@@ -210,10 +357,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:
+4 -8
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:
@@ -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:
+4
View File
@@ -1,5 +1,6 @@
.next
.vercel
.open-next
# Generated
packages/emoji-codepoints/index.ts
@@ -8,3 +9,6 @@ packages/icons/src/data/*.json
# Build files
dist/
README.md
CHANGELOG.md
+24 -2
View File
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=18.x)
- Bun
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
### Set up
@@ -78,6 +80,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.
+5905
View File
File diff suppressed because it is too large Load Diff
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" }
+16 -7
View File
@@ -2,20 +2,24 @@
"name": "gitbook",
"version": "0.1.0",
"devDependencies": {
"@changesets/cli": "^2.27.7",
"@changesets/cli": "^2.27.12",
"prettier": "^3.0.3",
"turbo": "^2.1.2"
"turbo": "^2.4.1-canary.2",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
@@ -23,6 +27,7 @@
"format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
@@ -31,5 +36,9 @@
},
"workspaces": [
"packages/*"
]
],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
}
}
+8
View File
@@ -0,0 +1,8 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
+7
View File
@@ -0,0 +1,7 @@
# gitbook-v2
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
+104
View File
@@ -0,0 +1,104 @@
# GitBook V2
This package contains the code for an experiment new version of the GitBook renderer.
The goals of this new version are:
- Optimize rendering to leverage Next.js ISR and static rendering
- Leverage Node.js runtime and bigger instances to render bigger content
- Evaluate Vercel and Cloudflare deployment options
## Deployment
#### Vercel
The new version is deployed automatically to Vercel using a Github Action on every push.
#### Cloudflare with Opennext-js
The new version is deployed automatically to Cloudflare using a Github Action on every push.
## Potential issues
- [ ] We can't use `dynamicIO` because our root layout needs to fetch the params (https://github.com/vercel/next.js/discussions/71927)
- [ ] How to do `nonce` for static resources when we can't use `headers()` (dynamic API) (https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy)
## Structure
#### Middleware:
Goals:
- Route between dynamic and static routes
- If a token is in the cookies => dynamic route
- If a token is in the url => dynamic route
- Else => static route
- High performances:
- It should not rely on data fetching
#### URLs to support:
Content URLs:
- `docs.company.com/page`
- Middleware: rewrite to `/static/url/docs.company.com/page`
- Page component renders the page
- `docs.company2.com/section/page`
- Middleware: rewrite to `/static/url/docs.company2.com/section/page`
- Page component renders the page
- `docs.company3.com/section/sitespace/page`
- Middleware: rewrite to `/static/url/docs.company3.com/section/sitespace/page`
- Page component renders the page
Content URLs (adaptive content or visitor authentication):
- `docs.company.com/page`
- Middleware:
- If a token is in the cookies or url => dynamic route
- Else => rewrite to `/dynamic/url/docs.company.com/page`
- Page component renders the page
- Read the token from the url query params or cookies
PDF export URLs:
- `docs.company.com/page/~gitbook/pdf`
- Middleware: rewrite to `/dynamic/pdf/docs.company.com/page`
- Page component renders the page
Images resizing:
- `docs.company.com/~gitbook/image`
- Middleware: rewrite to `/dynamic/image/docs.company.com`
- Route handlers process the request
Preview URLs:
- `open.gitbook.com/sites/site_abc/page` (or `open.gitbook.com/~site/site_abc/page` to stay compatible?)
- Middleware: no rewrite
- Page component renders the page (dynamic?)
- Read the token from the url query params
- Read customizations override from the url query params
Space PDF URLs:
- `open.gitbook.com/spaces/space_abc/pdf`
- Middleware: no rewrite
- Page component renders the page (dynamic?)
- Read the token from the url query params
- Read customizations override from the url query params
Development:
- `localhost:3000/sites/site_abc/page`
#### Routes:
- Content
- [ ] `/static/url/[[...url]]/page.tsx`
- [ ] `/dynamic/url/[[...url]]/page.tsx`
- Images resizing
- [ ] `/dynamic/image/[hostname]/route.ts`
- PDF export
- [ ] `/dynamic/pdf/[[...url]]/page.tsx`
- [ ] `/spaces/[space]/pdf/page.tsx`
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
// We can't use dynamicIO because it doesn't accept reading params in the root layout
// dynamicIO: true,
},
};
export default nextConfig;
+30
View File
@@ -0,0 +1,30 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import cache from '@opennextjs/cloudflare/kvCache';
const config = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
dangerous: {
enableCacheInterception: false,
},
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"name": "gitbook-v2",
"version": "0.1.0",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "^0.93.0"
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.4.3"
},
"scripts": {
"dev:v2": "next",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771"
}
}
@@ -0,0 +1,15 @@
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
import { createDynamicSiteContext } from '@/lib/context';
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ url: string[] }>;
children: React.ReactNode;
}) {
const { url } = await params;
const context = await createDynamicSiteContext(url);
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
}
@@ -0,0 +1,9 @@
import { SiteContentPage } from '@/components/routes/SiteContentPage';
import { createDynamicSiteContext } from '@/lib/context';
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
const { url } = await params;
const context = await createDynamicSiteContext(url);
return <SiteContentPage context={context} />;
}
@@ -0,0 +1,21 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
import { getSiteCacheTag } from '@/lib/cache';
import { createStaticSiteContext } from '@/lib/context';
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ url: string[] }>;
children: React.ReactNode;
}) {
'use cache';
const { url } = await params;
const context = await createStaticSiteContext(url);
cacheTag(getSiteCacheTag(context.siteId));
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
}
@@ -0,0 +1,15 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { createStaticSiteContext } from '@/lib/context';
import { getSiteCacheTag } from '@/lib/cache';
import { SiteContentPage } from '@/components/routes/SiteContentPage';
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
'use cache';
const { url } = await params;
const context = await createStaticSiteContext(url);
cacheTag(getSiteCacheTag(context.siteId));
return <SiteContentPage context={context} />;
}
@@ -0,0 +1,27 @@
import { GitBookSiteContext } from '@/lib/context';
/**
* Layout component to render the site content.
*/
export async function SiteContentLayout({
context,
children,
}: {
context: GitBookSiteContext;
children: React.ReactNode;
}) {
const { api } = context;
const { data: publishedSite } = await api.orgs.getPublishedContentSite(
context.organizationId,
context.siteId,
);
return (
<html lang="en">
<body>
<h1>{publishedSite.site.title}</h1>
{children}
</body>
</html>
);
}
@@ -0,0 +1,12 @@
import { GitBookSiteContext } from '@/lib/context';
export async function SiteContentPage({ context }: { context: GitBookSiteContext }) {
const { api } = context;
const { data } = await api.orgs.getPublishedContentSite(context.organizationId, context.siteId);
return (
<div>
<p>Found site {data.site.id}</p>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function getSiteCacheTag(siteId: string) {
return `sites/${siteId}`;
}
+112
View File
@@ -0,0 +1,112 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { GitBookAPI } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
/**
* Generic context about rendering content.
*/
export interface GitBookContext {
/**
* API client to fetch data from GitBook.
*/
api: GitBookAPI;
}
/**
* Context when rendering a site.
*/
export interface GitBookSiteContext extends GitBookContext {
/**
* ID of the organization.
*/
organizationId: string;
/**
* Site ID.
*/
siteId: string;
}
/**
* Create a site context, when rendering a static page.
*/
export async function createStaticSiteContext(url: string[]): Promise<GitBookSiteContext> {
const context = createStaticContext();
return fetchSiteContext(url, context);
}
/**
* Create a site context, when rendering a dynamic page.
*/
export async function createDynamicSiteContext(url: string[]): Promise<GitBookSiteContext> {
const context = await createDynamicContext();
return fetchSiteContext(url, context);
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
async function fetchSiteContext(
urlParts: string[],
baseContext: GitBookContext,
): Promise<GitBookSiteContext> {
const { api } = baseContext;
const url = getURLFromParams(urlParts);
const { data } = await api.urls.getPublishedContentByUrl({
url,
});
if ('redirect' in data) {
redirect(data.redirect);
}
return {
...baseContext,
api: new GitBookAPI({
endpoint: api.endpoint,
authToken: data.apiToken,
userAgent: api.userAgent,
}),
siteId: data.site,
organizationId: data.organization,
};
}
/**
* Create the base context when rendering statically.
*/
function createStaticContext(): GitBookContext {
const api = new GitBookAPI({
authToken: GITBOOK_API_TOKEN,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
return {
api,
};
}
/**
* Create the base context when rendering dynamically.
* The context will depend on the request.
*/
async function createDynamicContext(): Promise<GitBookContext> {
const headersSet = await headers();
const api = new GitBookAPI({
authToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
endpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
return {
api,
};
}
function getURLFromParams(input: string[]) {
const url = new URL('https://' + input.join('/'));
return url.toString();
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Default GitBook API URL endpoint.
*/
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
/**
* Default GitBook API token.
* It can be use to avoid rate-limiting.
*/
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
/**
* User agent to use for API requests.
*/
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
};
export function middleware(request: NextRequest) {
// No rewrite
return NextResponse.next();
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@v1/*": ["../gitbook/src/*"]
}
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+5
View File
@@ -0,0 +1,5 @@
main = ".open-next/worker.js"
name = "gitbook-v2"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
+170
View File
@@ -1,5 +1,175 @@
# gitbook
## 0.6.1
### Patch Changes
- dddb4ec: Fix long tab group description
- Updated dependencies [f8d4c76]
- Updated dependencies [dddb4ec]
- Updated dependencies [f8d4c76]
- @gitbook/react-openapi@1.0.1
## 0.6.0
### Minor Changes
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
- 95f2aa4: Track new events for site insights when ads are being clicked
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
- 1138d59: Add support for sidebar background styles
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
- 7059c2b: Add support for computed content by fetching computed documents for pages.
- c71d159: Track events for site insights using the new dedicated API.
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
- 7ee9158: Restyle PageAside to use sidebar list styles
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
- 1417279: Track clicks on links (header, footer, content) for site insights.
- 9eca010: Improve the display of recommended questions by streaming them.
- 160fca1: new OpenAPI blocks design
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
- 1823101: Fix internal properties appearing in OpenAPI docs.
- 6a073e1: Add antialiasing for text rendering
- 8126a83: Improve readability of tables with hover style and vertical dividers
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
### Patch Changes
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
- c30bc24: Fix empty sitemap
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
- 5b4e710: Support llms.txt
- b6c3870: Add support for keyboard marks
- 6059efe: Fix search no results error showing while there are results
- c77142a: Log component stack in Sentry
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
- 32aa1f9: Handle security issue with cookies on Safari
- d935fb1: Don't add extra page scroll when footer is not present
- 53de5b1: Fix site section URL resolution in Ask AI sources
- 24f5249: Fix vertical section overflow color
- 1762f85: Reduce gap between subsequent header buttons
- c1e27cc: Fix pass Sentry release properly
- 5ae1b88: Fix shrinking page icons
- 8f046a9: Start using tint in more places, TOC and PageAside
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
To reduce the load on the API and also avoid errors.
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
- 8cfa67c: Fix default outline list styling
- d66c184: Ignore errors from event flushing
- 6088fa5: Simplify search results logic to investigate a bug
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
- 09c7c30: Try to fix error on og image generation
- ae99f87: Improve emoji setup, align with GitBook app
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
- 3a7210d: Fix zoom image view transition on Safari
- 718a8a5: Position the variant picker in the ToC
- e5dc05e: Update footer styling and allow for more than 4 footer groups
- 8276ba0: Make cookies access safer
- 1b8a456: Fix Image blocks zoomable behaviour
- 56c52e0: Handle Firefox security error on localStorage
- 0510b6f: Add section description to SectionGroupTile
- 1fcc807: Fix errors from customization not found
- 46edde9: Improve the OpenAPI package API
- 8af1abc: Improve contrast of search box placeholder
- 92b7668: Improve header offset
- d9c8d57: Do not dereference before caching OpenAPI spec.
- 94876e3: Fix regression issue with page icons for multi-line titles
- 47971dc: Fix OG image generation for non-latin characters
- 82dc9c4: Simplify the `useHash` algo used.
- 128ad20: Ignore cache invalidation error from local backend
- ff05e20: Improvements to inline images in headings
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
- 48ab59f: Improve colour contrast of list item decoration
- d2bc567: Set Sentry release
- 37d13d8: Avoid error on fetch by passing a string URL
- d3e573c: Generate sitemap for all sections and spaces
- f7b801b: Add feedback form to page rating control
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
- 46f63cb: Fix code format overriding inline link styles
- 5950657: Fix emojis display
- 528a053: Fix server actions stability leading to no results found sometimes on search
- eac1314: Lazy load iframely script to make page more responsive
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
- 6f54826: New highlight colors
- 5c87ec7: Implement a safer way to interact with localStorage.
If it's disabled on the browser it should not throw error.
- 02d876e: Fix search UI behaviour
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
- aaab157: Visual fix for section group in Safari
- cbe6139: Fix dynamic tabs infinite loop
- 65cc4af: Fix error when accessing a change request not found
- 727bde2: Improve and split OpenAPI parser into its own package
- 0b6ddca: Fix variant selector contrast for non-default themes
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
- fde32e2: Force route handler to be dynamic to avoid errors
- a025118: Change card layout depending on cover aspect ratio
- 300f7bf: Fix search loading state
- 29d5979: Disable C/C++ highlight temporarily
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
- 1c97536: Fix Sentry instrumentation
- b0bd871: Even safer localStorage
- b950a64: Avoid errors on legacy browsers
- 38061bd: Add section groups to section tabs
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
- 6691492: Fix viewing PDF from space
- e8e64bf: Fix bullet list display on full size blocks
- 16194c5: Vertical orientation for sections list on sites without header
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
- 1429384: Fix error when accessing some not found pages.
- 21cbd9e: Change link color to primary-subtle
- 5dab70f: Fix "Parser" language syntax highlighting
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
- 44a20fe: Improve smoothness of scroll listener
- 5664e5a: Fix variant dropdown styling in header
- 6b50360: Fix view transition error on Safari
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
- 5112e3e: Fix Sentry instrumentation server-side
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
- Updated dependencies [d9029c7]
- Updated dependencies [6e54a06]
- Updated dependencies [162b4b7]
- Updated dependencies [e4e2f52]
- Updated dependencies [0c03676]
- Updated dependencies [3e5e458]
- Updated dependencies [46edde9]
- Updated dependencies [d9c8d57]
- Updated dependencies [ccf2cff]
- Updated dependencies [dda0cc6]
- Updated dependencies [eb7c22f]
- Updated dependencies [ea1468c]
- Updated dependencies [648f0e9]
- Updated dependencies [160fca1]
- Updated dependencies [f92e906]
- Updated dependencies [e721f17]
- Updated dependencies [727bde2]
- Updated dependencies [dff08ae]
- Updated dependencies [fc7b16f]
- Updated dependencies [fe8acc9]
- Updated dependencies [1823101]
- Updated dependencies [a652958]
- Updated dependencies [2f73db7]
- Updated dependencies [160fca1]
- Updated dependencies [12c7862]
- Updated dependencies [b41d425]
- @gitbook/react-openapi@1.0.0
- @gitbook/openapi-parser@1.0.0
- @gitbook/react-contentkit@0.6.0
## 0.5.0
### Minor Changes
+240
View File
@@ -0,0 +1,240 @@
import { runTestCases, TestsCase, waitForCookiesDialog } from './util';
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
{
name: 'Snyk',
baseUrl: 'https://docs.snyk.io',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
],
},
{
name: 'Nexthink',
baseUrl: 'https://docs.nexthink.com',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'asiksupport-stg.dto.kemkes.go.id',
baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'jasons-tutorials.gitbook.io',
baseUrl: 'https://jasons-tutorials.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.deltaemulator.com',
baseUrl: 'https://faq.deltaemulator.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dify.ai',
baseUrl: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'seeddao.gitbook.io',
baseUrl: 'https://seeddao.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.altstore.io',
baseUrl: 'https://faq.altstore.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'support.audacityteam.org',
baseUrl: 'https://support.audacityteam.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gmgn.ai',
baseUrl: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.spicychat.ai',
baseUrl: 'https://docs.spicychat.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.portainer.io',
baseUrl: 'https://docs.portainer.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.chirptoken.io',
baseUrl: 'https://docs.chirptoken.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dexscreener.com',
baseUrl: 'https://docs.dexscreener.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.pancakeswap.finance',
baseUrl: 'https://docs.pancakeswap.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'book.character.ai',
baseUrl: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tradeonnova.io',
baseUrl: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'azcoiner.gitbook.io',
baseUrl: 'https://azcoiner.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.midas.app',
baseUrl: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.keeper.io',
baseUrl: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'adiblar.gitbook.io',
baseUrl: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gradient.network',
baseUrl: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'mygate-network.gitbook.io',
baseUrl: 'https://mygate-network.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'treasurenft.gitbook.io',
baseUrl: 'https://treasurenft.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'browndust2.gitbook.io',
baseUrl: 'https://browndust2.gitbook.io',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'junookyo.gitbook.io',
baseUrl: 'https://junookyo.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'meshnet.nordvpn.com',
baseUrl: 'https://meshnet.nordvpn.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'manual.bubble.io',
baseUrl: 'https://manual.bubble.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tickettool.xyz',
baseUrl: 'https://docs.tickettool.xyz',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'wiki.redmodding.org',
baseUrl: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherry-ai.com',
baseUrl: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.snyk.io',
baseUrl: 'https://docs.snyk.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.realapp.link',
baseUrl: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.plaza.finance',
baseUrl: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.publicai.io',
baseUrl: 'https://docs.publicai.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'hyperliquid.gitbook.io',
baseUrl: 'https://hyperliquid.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.umbraco.com',
baseUrl: 'https://docs.umbraco.com',
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'sosovalue-white-paper.gitbook.io',
baseUrl: 'https://sosovalue-white-paper.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.revrobotics.com',
baseUrl: 'https://docs.revrobotics.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'chartschool.stockcharts.com',
baseUrl: 'https://chartschool.stockcharts.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.soniclabs.com',
baseUrl: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.meshchain.ai',
baseUrl: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.thousandeyes.com',
baseUrl: 'https://docs.thousandeyes.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.raydium.io',
baseUrl: 'https://docs.raydium.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.solv.finance',
baseUrl: 'https://docs.solv.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.fluentbit.io',
baseUrl: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/' }],
},
];
runTestCases(testCases);
@@ -1,63 +1,32 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale,
CustomizationThemeMode,
SiteCustomizationSettings,
CustomizationSidebarListStyle,
} from '@gitbook/api';
import { test, expect, Page } from '@playwright/test';
import deepMerge from 'deepmerge';
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { getContentTestURL } from '../tests/utils';
import {
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
VISITOR_TOKEN_COOKIE,
} from '@/lib/visitor-token';
interface Test {
name: string;
url: string; // URL to visit for testing
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
only?: boolean; // Only run this test
}
interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
const allThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
const reject = dialog.getByRole('button', { name: 'Reject' });
await expect(accept).toBeVisible();
await expect(reject).toBeVisible();
}
import {
allDeprecatedThemePresets,
allLocales,
allSidebarBackgroundStyles,
allThemeModes,
allThemes,
allTintColors,
getCustomizationURL,
headerLinks,
runTestCases,
TestsCase,
waitForCookiesDialog,
} from './util';
const testCases: TestsCase[] = [
{
@@ -81,19 +50,28 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
@@ -254,19 +232,28 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
@@ -295,6 +282,9 @@ const testCases: TestsCase[] = [
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
screenshot: {
waitForTOCScrolling: false,
},
},
],
},
@@ -317,11 +307,38 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Images (with zoom)',
url: 'blocks/block-images',
run: async (page) => {
await waitForCookiesDialog(page);
const zoomImage = page.getByTestId('zoom-image');
await zoomImage.first().click();
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
},
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Inline Images',
url: 'blocks/inline-images',
run: waitForCookiesDialog,
run: async (page) => {
await waitForCookiesDialog(page);
// Make the text invisible to fix flakiness due to the text position.
await page.evaluate(() => {
for (const p of document.querySelectorAll('p')) {
if (
p.textContent?.includes(
'This image has intrinsic 400px width, but renders as 300px:',
)
) {
p.style.color = 'transparent';
}
}
});
},
},
{
name: 'Tabs',
@@ -392,6 +409,18 @@ const testCases: TestsCase[] = [
{
name: 'Math',
url: 'blocks/math',
run: async (page) => {
await page.waitForFunction(() => {
const fonts = Array.from(document.fonts.values());
const mjxFonts = fonts.filter(
(font) => font.family === 'MJXZERO' || font.family === 'MJXTEX',
);
return (
mjxFonts.length === 2 &&
mjxFonts.every((font) => font.status === 'loaded')
);
});
},
},
{
name: 'Files',
@@ -445,6 +474,9 @@ const testCases: TestsCase[] = [
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
},
{
name: 'With icon',
@@ -456,23 +488,23 @@ const testCases: TestsCase[] = [
{
name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: allThemeModes.flatMap((theme) => [
tests: allThemeModes.flatMap((themeMode) => [
{
name: `Without header - Theme ${theme}`,
name: `Without header - Theme mode ${themeMode}`,
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.None,
links: [],
},
themes: {
default: theme,
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: `With duotone icons - Theme ${theme}`,
name: `With duotone icons - Theme mode ${themeMode}`,
url:
'page-options/page-with-icon' +
getCustomizationURL({
@@ -480,119 +512,108 @@ const testCases: TestsCase[] = [
icons: CustomizationIconsStyle.Duotone,
},
themes: {
default: theme,
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: `With header buttons - Theme ${theme}`,
name: `With header buttons - Theme mode ${themeMode}`,
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',
},
],
links: headerLinks,
},
themes: {
default: theme,
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: `Without tint - Default preset - Theme ${theme}`,
name: `Without tint - Default preset - Theme mode ${themeMode}`,
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',
},
],
links: headerLinks,
},
themes: {
default: theme,
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
...allThemePresets.flatMap((preset) => ({
name: `With tint - Preset ${preset} - Theme ${theme}`,
url: getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
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',
// New site themes
...allThemes.flatMap((theme) => [
...allTintColors.flatMap((tint) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
url: getCustomizationURL({
styling: {
theme,
...(tint.value ? { tint: { color: tint.value } } : {}),
sidebar: {
background: sidebarStyle,
list: CustomizationSidebarListStyle.Default,
},
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
header: {
links: headerLinks,
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
]),
// Deprecated header themes
...allDeprecatedThemePresets.flatMap((preset) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `With tint - Legacy header preset ${preset} - Sidebar ${sidebarStyle} - Theme mode ${themeMode}`,
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: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
{
name: `With tint - Legacy background match - Theme ${theme}`,
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
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',
},
],
links: headerLinks,
},
themes: {
default: theme,
default: themeMode,
toggeable: false,
},
}),
@@ -621,11 +642,14 @@ const testCases: TestsCase[] = [
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -637,13 +661,15 @@ const testCases: TestsCase[] = [
name: 'Navigation to shared space',
url: '',
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
await page.locator('a.underline').click();
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -657,6 +683,7 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
screenshot: false,
},
],
},
@@ -676,6 +703,7 @@ const testCases: TestsCase[] = [
page.getByText('Authentication missing to access this content'),
).toBeVisible();
},
screenshot: false,
},
],
},
@@ -698,7 +726,12 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
},
{
name: 'Second',
@@ -715,7 +748,12 @@ const testCases: TestsCase[] = [
);
return `second?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'second' }),
).toBeVisible();
},
screenshot: false,
},
],
},
@@ -829,7 +867,12 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
},
{
name: 'Custom page',
@@ -867,6 +910,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/',
@@ -1022,6 +1119,149 @@ const testCases: TestsCase[] = [
},
],
},
{
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/',
@@ -1108,97 +1348,4 @@ const testCases: TestsCase[] = [
},
];
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 }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL);
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
}
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
fullPage: testEntry.fullPage ?? false,
});
}
});
}
});
}
/**
* Create a URL with customization settings.
*/
function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
/**
* 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,
},
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);
return `?${searchParams.toString()}`;
}
runTestCases(testCases);
+332
View File
@@ -0,0 +1,332 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemedColor,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { test, expect, Page, BrowserContext } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { getContentTestURL } from '../tests/utils';
export interface Test {
name: string;
/**
* URL to visit for testing.
*/
url: string;
cookies?: Parameters<BrowserContext['addCookies']>[0];
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
fullPage?: boolean;
/**
* Whether to take a screenshot of the test or set a threshold for the screenshot.
*/
screenshot?:
| false
| {
/**
* Screenshot threshold.
* From 0 to 1, where 0 is the most strict and 1 is the most permissive.
* @default 0.5
*/
threshold?: number;
/**
* Whether to wait for the table of contents to finish scrolling before taking the screenshot.
*/
waitForTOCScrolling?: boolean;
};
/**
* Whether to only run this test.
*/
only?: boolean;
}
export interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
export const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
export const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
export const allTintColors: Array<{
label: string;
value: CustomizationThemedColor | undefined;
}> = [
{
label: 'Off',
value: undefined,
},
{ label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } },
{ label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } },
];
export const allThemes: CustomizationTheme[] = [
CustomizationTheme.Clean,
CustomizationTheme.Muted,
CustomizationTheme.Bold,
CustomizationTheme.Gradient,
];
export const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
CustomizationSidebarBackgroundStyle.Default,
CustomizationSidebarBackgroundStyle.Filled,
];
// Common customization settings
export const headerLinks: CustomizationHeaderItem[] = [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
links: [],
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
links: [],
},
];
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
const reject = dialog.getByRole('button', { name: 'Reject' });
await expect(accept).toBeVisible();
await expect(reject).toBeVisible();
}
/**
* Transform test cases into Playwright tests and run it.
*/
export function runTestCases(testCases: TestsCase[]) {
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, 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);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async () => {
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
}
});
}
});
}
}
/**
* Create a URL with customization settings.
*/
export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
// We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API.
// We can remove this once the migration to the new themes have been completed and the new theme styles are verified
// Map the theme preset (+ tint) to one of the new themes
const newTheme = (() => {
if (partial.styling?.theme) {
return partial.styling.theme;
}
switch (partial.header?.preset) {
case CustomizationHeaderPreset.Bold:
case CustomizationHeaderPreset.Contrast:
case CustomizationHeaderPreset.Custom:
return CustomizationTheme.Bold;
case CustomizationHeaderPreset.None:
case CustomizationHeaderPreset.Default:
if (partial.styling?.tint) {
return CustomizationTheme.Muted;
}
return CustomizationTheme.Clean;
default:
return CustomizationTheme.Clean;
}
})();
/**
* 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: {
theme: newTheme,
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);
return `?${searchParams.toString()}`;
}
/**
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(async () => {
function loadImage(src: string) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
await Promise.all(
icons.map(async (icon) => {
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
throw new Error('No mask-image');
}
await loadImage(url);
}),
);
});
}
/**
* Wait for TOC to be correctly scrolled into view.
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
const tocScrollContainer = document.querySelector(
'[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]',
);
if (!tocScrollContainer) {
throw new Error('TOC scroll container not found');
}
tocScrollContainer.scrollTo(0, 0);
});
}
}
+5 -7
View File
@@ -4,11 +4,13 @@ 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,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
webpack(config, { dev, webpack }) {
@@ -67,19 +69,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,
},
);
+21 -16
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.5.0",
"version": "0.6.1",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -9,26 +9,29 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"e2e": "playwright test",
"e2e": "playwright test e2e/internal.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"unit": "bun test {src,packages}",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
},
"dependencies": {
"@gitbook/api": "^0.81.0",
"@gitbook/api": "^0.93.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
"@sentry/nextjs": "8.35.0",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
@@ -40,19 +43,18 @@
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"next": "14.2.15",
"next": "14.2.23",
"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.3.1",
"react-dom": "18.3.1",
"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,20 +62,23 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.23.1",
"shiki": "^1.27.2",
"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.5",
"@cloudflare/workers-types": "^4.20240725.0",
"@playwright/test": "^1.42.1",
"@argos-ci/playwright": "^3.10.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
+9 -18
View File
@@ -1,27 +1,18 @@
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
import * as Sentry from '@sentry/browser';
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,
release: process.env.SENTRY_RELEASE,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
// 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;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
});
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;
},
});
}
@@ -1,12 +1,12 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature,
resizeImage,
CloudflareImageOptions,
imagesResizingSignVersion,
checkIsSizableImageURL,
} from '@/lib/images';
} from '@/lib/image-signatures';
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -20,12 +20,15 @@ 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 2, but we need to support the older version as well
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { 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
@@ -35,17 +38,16 @@ export async function GET(request: NextRequest) {
return new Response('Invalid url parameter', { status: 400 });
}
// For older signatures, we redirect to the url.
if (signatureVersion !== imagesResizingSignVersion) {
return Response.redirect(url, 302);
}
// Verify the signature
const verified = await verifyImageSignature(url, { signature });
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
if (!verified) {
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',
@@ -90,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;
}
@@ -1,24 +0,0 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer);
const language = getSpaceLanguage(customization);
return (
<div
className={tcls('flex-1', 'flex', 'flex-row', 'items-center', 'justify-center', 'py-9')}
>
<div className={tcls('max-w-80')}>
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
{t(language, 'notfound_title')}
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
</div>
);
}
@@ -1,95 +0,0 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = 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 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],
}
: {}),
},
};
});
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xsi:schemaLocation':
'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages(
rootPags: RevisionPage[],
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): FlatPageEntry[] {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
const allowed = filter(page);
if (!allowed) {
return [];
}
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) =>
child.type === 'document' ? flattenPage(child, depth + 1) : [],
),
];
};
return rootPags.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
);
}
@@ -15,7 +15,7 @@ export function RocketLoaderDetector(props: { nonce: string }) {
document.addEventListener("DOMContentLoaded", () => {
if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) {
const alert = document.createElement('div');
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 mt-8 mx-8';
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 mt-8 mx-8';
alert.innerHTML = \`
<strong>Error in site configuration:</strong>
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
@@ -0,0 +1,39 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = await getSiteContentPointer();
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
]);
const language = getSpaceLanguage(customization);
return (
<div
className={tcls(
'flex-1',
'flex',
'flex-row',
'items-center',
'justify-center',
'py-9',
'min-h-[calc(100vh-64px)] lg:min-h-fit',
)}
>
<div className={tcls('max-w-80')}>
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
{t(language, 'notfound_title')}
</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,26 +5,29 @@ 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';
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* 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,
@@ -50,12 +53,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;
@@ -83,20 +86,18 @@ export default async function Page(props: {
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
<div className="flex flex-row-reverse justify-end">
{page.layout.outline ? (
<PageAside
space={space}
site={site}
customization={customization}
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={contentRefContext}
/>
) : null}
<div className="flex flex-row-reverse justify-end grow">
<PageAside
space={space}
site={site}
customization={customization}
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={contentRefContext}
/>
<PageBody
space={space}
pointer={contentPointer}
@@ -106,10 +107,7 @@ export default async function Page(props: {
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
withPageFeedback={withPageFeedback}
/>
</div>
<React.Suspense fallback={null}>
@@ -119,8 +117,12 @@ export default async function Page(props: {
);
}
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
@@ -134,12 +136,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) {
@@ -153,16 +155,17 @@ export async function generateMetadata({
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: absoluteHref(getPagePath(pages, page), true).replace(/\/+$/, ''),
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',
};
@@ -186,7 +189,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';
@@ -21,6 +22,7 @@ import { RocketLoaderDetector } from './RocketLoaderDetector';
import { fetchContentData } from '../fetch';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* Layout when rendering the content.
@@ -28,7 +30,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 +44,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
sections,
} = await fetchContentData();
ReactDOM.preconnect(api().client.endpoint);
const apiCtx = await api();
ReactDOM.preconnect(apiCtx.client.endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
@@ -54,47 +57,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 +123,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;
@@ -0,0 +1,154 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import { ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { NextRequest } from 'next/server';
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/utils';
export const runtime = 'edge';
/**
* Generate a llms.txt file for the site.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const { structure: siteStructure, site } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('llms.txt is only served from the root of the site', { status: 404 });
}
const tree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(siteStructure)),
],
};
return new Response(
toMarkdown(tree, {
bullet: '-',
}),
{
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
switch (siteStructure.type) {
case 'sections':
return getNodesFromSections(getSiteStructureSections(siteStructure));
case 'siteSpaces':
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
default:
assertNever(siteStructure);
}
}
/**
* Get MDAST nodes from site sections.
*/
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
heading: false,
});
return [
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSection.title }],
},
...siteSpaceNodes,
];
}),
);
return all.flat();
}
/**
* Get MDAST nodes from site spaces.
*/
async function getNodesFromSiteSpaces(
siteSpaces: SiteSpace[],
options: {
/**
* Includes a heading for each site space.
*/
heading?: boolean;
},
): Promise<RootContent[]> {
const all = await Promise.all(
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
return [];
}
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
metadata: false,
});
const pages = getIndexablePages(rootPages);
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const url = await getAbsoluteHref(
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
true,
);
const children: Paragraph['children'] = [
{
type: 'link',
url,
children: [{ type: 'text', value: page.title }],
},
];
if (page.description) {
children.push({ type: 'text', value: `: ${page.description}` });
}
return {
type: 'listItem',
children: [{ type: 'paragraph', children }],
};
}),
);
const nodes: RootContent[] = [];
if (options.heading) {
nodes.push({
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSpace.title }],
});
}
nodes.push({
type: 'list',
spread: false,
children: listChildren,
});
return nodes;
}),
);
return all.flat();
}
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -11,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),
@@ -20,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');
@@ -0,0 +1,66 @@
import jsontoxml from 'jsontoxml';
import { getRevisionPages, getSpace } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current section / space.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const revisionId =
pointer.revisionId ?? (await getSpace(pointer.spaceId, pointer.siteShareKey)).revision;
const rootPages = await getRevisionPages(pointer.spaceId, revisionId, { metadata: false });
const pages = getIndexablePages(rootPages);
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 url: { loc: string; priority: number; lastmod?: string } = {
priority: normalizedPriority,
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
};
if (lastModified) {
url.lastmod = new Date(lastModified).toISOString();
}
return { url };
}),
);
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
@@ -0,0 +1,98 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import jsontoxml from 'jsontoxml';
import { getPublishedContentSite } from '@/lib/api';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { filterOutNullable } from '@/lib/typescript';
import { getSiteStructureSections } from '@/lib/utils';
export const runtime = 'edge';
/**
* Generate a root sitemap that point to all sitemap-pages.xml.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const { structure: siteStructure } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('Root sitemap is only served from the root of the site', {
status: 404,
});
}
const urls = await getUrlsFromSiteStructure(siteStructure);
const xml = jsontoxml(
[
{
name: 'sitemapindex',
children: urls.map((url) => ({
name: 'sitemap',
children: [{ name: 'loc', text: url }],
})),
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
/**
* Get Sitemap URLs from site structure.
*/
async function getUrlsFromSiteStructure(siteStructure: SiteStructure): Promise<string[]> {
switch (siteStructure.type) {
case 'sections':
return getUrlsFromSiteSections(getSiteStructureSections(siteStructure));
case 'siteSpaces':
return getUrlsFromSiteSpaces(siteStructure.structure);
default:
assertNever(siteStructure);
}
}
/**
* Get Sitemap URLs from site sections.
*/
async function getUrlsFromSiteSections(siteSections: SiteSection[]): Promise<string[]> {
const urls = await Promise.all(
siteSections.map(async (siteSection) => getUrlsFromSiteSpaces(siteSection.siteSpaces), []),
);
return urls.flat();
}
/**
* Get Sitemap URLs from site spaces.
*/
async function getUrlsFromSiteSpaces(siteSpaces: SiteSpace[]): Promise<string[]> {
const urls = await Promise.all(
siteSpaces.map(async (siteSpace) => {
if (!siteSpace.urls.published) {
return null;
}
const url = new URL(siteSpace.urls.published);
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
return url.toString();
}, []),
);
return urls.filter(filterOutNullable);
}
@@ -35,7 +35,7 @@ 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([
@@ -48,7 +48,7 @@ export async function GET(req: NextRequest) {
return new ImageResponse(
(
<div
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-gray-900', size.boxStyle)}
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)}
style={{
width: '100%',
height: '100%',
@@ -62,7 +62,7 @@ export async function GET(req: NextRequest) {
size.textSize,
'font-bold',
'tracking-tight',
options.theme === 'light' ? 'text-gray-900' : 'text-white',
options.theme === 'light' ? 'text-black' : 'text-white',
)}
>
{'emoji' in customization.favicon
@@ -2,47 +2,100 @@ import { CustomizationHeaderPreset } from '@gitbook/api';
import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import colorContrast from 'postcss-color-contrast/js';
import React from 'react';
import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { googleFontsMap } from '@/fonts';
import { colorContrast } from '@/lib/colors';
import { getAbsoluteHref } from '@/lib/links';
import { filterOutNullable } from '@/lib/typescript';
import { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
async function loadGoogleFont(input: { fontFamily: string; text: string; weight: 400 | 700 }) {
const { fontFamily, text, weight } = input;
if (!text.trim()) {
return null;
}
const url = new URL('https://fonts.googleapis.com/css2');
url.searchParams.set('family', `${fontFamily}:wght@${weight}`);
url.searchParams.set('text', text);
const result = await fetch(url.href);
if (!result.ok) {
return null;
}
const css = await result.text();
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
const resourceUrl = resource ? resource[1] : null;
if (resourceUrl) {
const response = await fetch(resourceUrl);
if (response.ok) {
const data = await response.arrayBuffer();
return {
name: fontFamily,
data,
style: 'normal' as const,
weight,
};
}
}
// If for some reason we can't load the font, we'll just use the default one
return null;
}
/**
* 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 user configured a custom social preview, we redirect to it.
if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
redirect(customization.socialPreview.url);
}
// TODO: Support all fonts available in GitBook
// Right now this is impossible since next/font/google does not expose the cached font file
// Another option would be to use the Satori prop `loadAdditionalAsset` [example](https://github.com/vercel/satori/blob/main/playground/pages/index.tsx),
// but this prop isn't (yet) exposed through `ImageResponse`.
const interRegular = await fetch(
new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
).then((res) => res.arrayBuffer());
const interBold = await fetch(
new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
).then((res) => res.arrayBuffer());
// Compute all text to load only the necessary fonts
const contentTitle = customization.header.logo
? ''
: getContentTitle(space, customization, site ?? null);
const pageTitle = page
? page.title.length > 64
? page.title.slice(0, 64) + '...'
: page.title
: 'Not found';
const pageDescription =
page?.description && page?.title.length <= 64
? page.description.length > 164
? page.description.slice(0, 164) + '...'
: page.description
: '';
const fontFamily = googleFontsMap[customization.styling.font] ?? 'Inter';
const regularText = pageDescription;
const boldText = `${contentTitle}${pageTitle}`;
const fonts = (
await Promise.all([
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
])
).filter(filterOutNullable);
const theme = customization.themes.default;
const useLightTheme = theme === 'light';
// We have no access to CSS variables, so we'll have to hardcode some values
const baseColors = {
light: '#ffffff',
dark: '#111827',
};
const baseColors = { light: '#ffffff', dark: '#111827' };
let colors = {
background: baseColors[theme],
@@ -51,8 +104,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,55 +145,41 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
break;
}
const favicon = function () {
const favicon = await (async () => {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw={tcls('mr-4')}
tw="mr-4"
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw={tcls('text-4xl', 'mr-4')}>
<span tw="text-4xl mr-4">
{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="mr-4" />;
})();
return new ImageResponse(
(
<div
tw={tcls(
'justify-between',
'p-20',
'relative',
'w-full',
'h-full',
'flex',
'flex-col',
`bg-[${colors.background}]`,
`text-[${colors.body}]`,
)}
tw={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`}
style={{
fontFamily,
}}
>
{/* Gradient */}
<div
tw={tcls('absolute', 'inset-0')}
tw="absolute inset-0"
style={{
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
opacity: 0.5,
@@ -146,11 +187,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
></div>
{/* Grid */}
<img
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
src={gridAsset}
alt="Grid"
/>
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
{/* Logo */}
{customization.header.logo ? (
@@ -164,39 +201,21 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
}
/>
) : (
<div tw={tcls('flex')}>
{favicon()}
<h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
<div tw="flex">
{favicon}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
</div>
)}
{/* Title and description */}
<div tw={tcls('flex', 'flex-col')}>
<div tw="flex flex-col">
<h1
tw={tcls(
'text-8xl',
'my-0',
'tracking-tight',
'leading-none',
'text-left',
`text-[${colors.title}]`,
'font-bold',
)}
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
>
{page
? page.title.length > 64
? page.title.slice(0, 64) + '...'
: page.title
: 'Not found'}
{pageTitle}
</h1>
{page?.description && page?.title.length <= 64 ? (
<h2 tw={tcls('text-4xl', 'mb-0', 'mt-8', 'w-[75%]', 'font-normal')}>
{page.description.length > 164
? page.description.slice(0, 164) + '...'
: page.description}
</h2>
{pageDescription ? (
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
) : null}
</div>
</div>
@@ -204,20 +223,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: interRegular,
weight: 400,
style: 'normal',
},
{
name: 'Inter',
data: interBold,
weight: 700,
style: 'normal',
},
],
fonts: fonts.length ? fonts : undefined,
},
);
}
@@ -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 (
@@ -3,10 +3,10 @@ import { redirect } from 'next/navigation';
import {
getRevisionPageByPath,
getDocument,
getSpaceContentData,
getSiteData,
getSiteRedirectBySource,
getPageDocument,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -23,7 +23,7 @@ export interface PageIdParams {
* 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, site, sections, spaces, scripts }] =
await Promise.all([
@@ -65,9 +65,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
shareKey: contentData.shareKey,
params,
});
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
: null;
const document = page ? await getPageDocument(contentData.space.id, page.page) : null;
return {
...contentData,
@@ -9,7 +9,7 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const { customization } = await getSiteData(pointer);
return (
@@ -133,7 +133,7 @@ export function PageControlButtons(props: {
'items-center',
'justify-center',
'text-lg',
'text-dark/6',
'text-tint',
'px-6',
'py-2',
'bg-slate-100',
@@ -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
? getSiteData(pointer)
: getSpaceLayoutData(pointer.spaceId));
: 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) {
async function getSpaceLayoutData() {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(spaceId),
getSpaceCustomization(),
[] as SpaceIntegrationScript[],
]);
@@ -4,6 +4,7 @@ import {
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -22,8 +23,9 @@ import {
getSpaceCustomization,
getSpaceContentData,
getSiteData,
getPageDocument,
} 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';
@@ -37,12 +39,13 @@ import { PrintButton } from './PrintButton';
const DEFAULT_LIMIT = 100;
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF();
const pointer = await getSiteOrSpacePointerForPDF();
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
]);
return {
@@ -54,19 +57,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 ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -74,7 +79,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),
@@ -86,17 +91,16 @@ 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',
'items-center',
'justify-center',
'text-sm',
'text-dark/6',
'text-tint',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
@@ -119,10 +123,9 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
'items-center',
'justify-center',
'text-sm',
'text-dark/6',
'text-tint',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
@@ -143,7 +146,11 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={
customization.trademark.enabled ? (
<TrademarkLink space={space} customization={customization} />
<TrademarkLink
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
) : null
}
/>
@@ -156,7 +163,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>
}
@@ -201,7 +208,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',
@@ -225,11 +232,10 @@ async function PDFPageDocument(props: {
refContext: ContentRefContext;
}) {
const { space, page, refContext } = props;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
const document = await getPageDocument(space.id, page);
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>
@@ -248,7 +254,7 @@ async function PDFPageDocument(props: {
},
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
getId: (id) => getPagePDFContainerId(page, id),
}}
/>
) : null}
@@ -8,9 +8,11 @@ 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();
return await getSiteContentPointer();
} catch (error) {
return getSpacePointer();
}
@@ -1,5 +1,6 @@
import { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { headers } from 'next/headers';
import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
@@ -14,33 +15,7 @@ interface AdminToolbarProps {
space: Space;
}
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const toolbar = (() => {
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
})();
if (!toolbar) {
return null;
}
function ToolbarLayout(props: { children: React.ReactNode }) {
return (
<div
className={tcls(
@@ -51,51 +26,88 @@ export function AdminToolbar(props: AdminToolbarProps) {
'transform',
'-translate-x-1/2',
'rounded-full',
'bg-dark-1/9',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'shadow-lg',
'min-h-10',
'min-w-40',
'p-2',
'max-w-md',
'border-dark-1',
'backdrop-blur-sm',
'border-tint-12/1',
'backdrop-blur-md',
)}
>
<React.Suspense fallback={null}>{toolbar}</React.Suspense>
<React.Suspense fallback={null}>{props.children}</React.Suspense>
</div>
);
}
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const mode = await headers().get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
return null;
}
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
}
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
const { spaceId, changeRequestId } = props;
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
if (!changeRequest) {
return null;
}
return (
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-light/8 dark:text-light/8">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-tint-2 dark:text-tint-11">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
}
@@ -107,31 +119,35 @@ async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
});
return (
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarLayout>
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-tint-2 dark:text-tint-11">
{revision.git.message}
</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
);
}
@@ -19,8 +19,8 @@ export function Toolbar(props: { children: React.ReactNode }) {
'py-1',
'rounded-full',
'truncate',
'text-light',
'dark:text-light',
'text-tint-1',
'dark:text-tint-12',
)}
>
{children}
@@ -50,12 +50,11 @@ export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
'gap-1',
'text-sm',
'rounded-full',
'hover:bg-dark-1',
'hover:text-white',
'truncate',
'text-light',
'dark:text-light',
'dark:hover:bg-dark-2',
'text-tint-1', // Equal to text-tint-strong in dark mode
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
'hover:bg-tint-12',
'dark:hover:bg-tint-1',
'hover:shadow-lg',
'cursor-pointer',
)}
+38 -41
View File
@@ -1,12 +1,21 @@
'use client';
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
import {
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import * as React from 'react';
import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd';
import { useHasBeenInViewport } from '../hooks/useHasBeenInViewport';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
/**
* Zone ID provided by BuySellAds for the preview.
@@ -28,46 +37,34 @@ export function Ad({
}: {
zoneId: string | null;
spaceId: string;
placement: string;
placement: SiteInsightsAdPlacement;
ignore: boolean;
style?: ClassValue;
siteAdsStatus?: SiteAds['status'];
mode?: 'classic' | 'auto' | 'cover';
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
const [ad, setAd] = React.useState<
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
>(undefined);
const trackEvent = useTrackEvent();
// Observe the container visibility
// Track display of the ad
React.useEffect(() => {
if (!containerRef.current) {
return;
if (ad?.insightsAd) {
trackEvent({
type: 'ad_display',
ad: ad.insightsAd,
});
}
}, [ad, trackEvent]);
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
}
},
{
root: null,
rootMargin: '0px',
threshold: 0.1,
},
);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, []);
const hasBeenInViewport = useHasBeenInViewport(containerRef, { threshold: 0.1 });
// When the container is visible,
// track an impression on the ad and fetch it
React.useEffect(() => {
if (!visible) {
if (!hasBeenInViewport) {
return;
}
@@ -111,13 +108,13 @@ export function Ad({
return () => {
cancelled = true;
};
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
}, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]);
return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? (
<>
{ad}
{ad.children}
<AdSponsoredLink spaceId={spaceId} />
</>
) : null}
@@ -128,6 +125,7 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
@@ -135,19 +133,18 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<p
className={tcls(
'mt-2',
'mr-2',
'text-xs',
'text-right',
'text-dark/5',
'dark:text-light/5',
)}
>
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
<Link
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
insights={{
type: 'trademark_click',
placement: SiteInsightsTrademarkPlacement.Ad,
}}
>
{t(language, 'sponsored_via_gitbook')}
</a>
</Link>
</p>
);
}
@@ -1,55 +1,65 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
import { Link } from '../primitives';
/**
* Classic rendering for an ad.
*/
export function AdClassicRendering({ ad }: { ad: AdItem }) {
export async function AdClassicRendering({
ad,
insightsAd,
}: {
ad: AdItem;
insightsAd: SiteInsightsAd | null;
}) {
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
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls(
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'bg-tint',
'text-tint',
'hover:text-tint-strong',
'rounded-lg',
'p-4',
)}
href={ad.statlink}
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>
</a>
</Link>
);
}
@@ -1,3 +1,4 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
@@ -5,27 +6,41 @@ import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
import { Link } from '../primitives';
/**
* 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,
insightsAd,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
}) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return (
<a
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls(
'group/ad',
'relative',
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'bg-tint-subtle',
'text-tint',
'hover:text-tint-strong',
'rounded-lg',
'p-4',
'overflow-hidden',
@@ -33,8 +48,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
@@ -106,6 +119,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</a>
</Link>
);
}
@@ -1,5 +1,6 @@
'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers';
import { AdClassicRendering } from './AdClassicRendering';
@@ -20,7 +21,7 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */
placement: string;
placement: SiteInsightsAdPlacement;
/** If true, we'll not track it as an impression */
ignore: boolean;
}
@@ -40,23 +41,35 @@ 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;
}
const { ad } = result;
return (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
);
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
}
async function fetchAd({
@@ -64,7 +77,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 +99,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 +132,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') ??
@@ -11,5 +11,8 @@ export async function hasContentBeenUpdated(props: {
revisionId: string;
}) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
}
@@ -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.
*/
@@ -43,11 +44,11 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
className={tcls(
'fixed',
'z-10',
'bg-white',
'bg-tint-base',
'rounded',
'straight-corners:rounded-none',
'ring-1',
'ring-dark/2',
'ring-tint-subtle',
'shadow-1xs',
'p-4',
'pr-8',
@@ -57,8 +58,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'max-w-md',
'text-balance',
'sm:left-auto',
'dark:ring-light/2',
'dark:bg-dark',
)}
>
<p id={describedById} className={tcls('text-sm')}>
@@ -67,7 +66,13 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'cookies_prompt',
<a
href={privacyPolicy}
className={tcls('text-primary-500', 'hover:text-primary-600', 'underline')}
className={tcls(
'text-primary-subtle',
'hover:text-primary',
'contrast-more:text-primary',
'contrast-more:hover:text-primary-strong',
'underline',
)}
>
{t(language, 'cookies_prompt_privacy')}
</a>,
@@ -87,8 +92,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'justify-center',
'items-center',
'rounded-sm',
'hover:bg-dark/2',
'dark:hover:bg-light/1',
'hover:bg-tint-hover',
)}
title={tString(language, 'cookies_close')}
>
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
import { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
const { inline, context, document, children } = props;
const fragment = getNodeFragmentByType(inline, 'annotation-body');
@@ -31,38 +31,32 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
className={tcls(
'text-sm',
'max-w-[280px]',
'bg-light',
'bg-tint',
'ring-1',
'ring-dark/2',
'ring-tint',
'rounded',
'shadow-1xs',
'shadow-dark/1',
'shadow-tint-12/1',
'dark:shadow-tint-1/2',
'p-3',
'[&_p]:leading-snug',
'dark:bg-dark',
'dark:ring-light/2',
'dark:shadow-dark/4',
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20',
)}
sideOffset={5}
sideOffset={4}
>
{body}
<Popover.Arrow asChild>
<svg
width="100%"
viewBox="0 0 8 5"
preserveAspectRatio="xMaxYMid meet"
className={tcls(
'relative',
'z-[2]',
'fill-light',
'stroke-dark/2',
'fill-tint-3', // Same as bg-tint
'stroke-tint-7', // Same as ring-tint
'[paint-order:stroke_fill]',
'dark:fill-dark',
'dark:stroke-light/2',
)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -1,6 +1,6 @@
import { DocumentBlockContentRef } from '@gitbook/api';
import { DocumentBlockContentRef, SiteInsightsLinkPosition } 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';
@@ -12,7 +12,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-dark/6', 'dark:text-light/6'],
iconStyle: ['text-xl', 'text-tint'],
});
if (!resolved) {
@@ -34,6 +34,13 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
href={resolved.href}
title={resolved.text}
style={style}
insights={{
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
/>
);
}
@@ -48,7 +55,7 @@ async function SpaceRefCard(
return null;
}
const { customization: spaceCustomization } = 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;
@@ -23,6 +23,7 @@ export function Caption(
fit?: boolean;
wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
} & DocumentContextProps,
) {
const {
@@ -31,22 +32,18 @@ export function Caption(
block,
context,
fit = false,
withBorder = false,
wrapperStyle = [
'relative',
'overflow-hidden',
'rounded',
'straight-corners:rounded-none',
'after:block',
'after:absolute',
'after:-inset-[0]',
'after:border-dark/2',
'after:border',
'after:rounded',
'straight-corners:after:rounded-none',
'dark:after:border-light/1',
'dark:after:mix-blend-plus-lighter',
'after:pointer-events-none',
fit ? 'w-fit' : null,
withBorder
? 'rounded straight-corners:rounded-none after:border-tint-subtle after:border after:rounded straight-corners:after:rounded-none dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null,
],
style,
} = props;
@@ -65,15 +62,7 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption
className={tcls(
'text-sm',
'text-center',
'mt-2',
'text-dark/7',
'dark:text-light/7',
)}
>
<figcaption className={tcls('text-sm', 'text-center', 'mt-2', 'text-tint')}>
<Inlines
nodes={captionParagraph.nodes}
document={document}
@@ -0,0 +1,56 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import type { HighlightLine, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const blockRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
// Check if the block is in the viewport to start highlighting it.
const hasBeenInViewport = useHasBeenInViewport(blockRef, {
rootMargin: '200px',
});
// Highlight the block when it's in the viewport.
useEffect(() => {
if (hasBeenInViewport) {
let canceled = false;
import('./highlight').then(({ highlight }) => {
// We use requestIdleCallback to avoid blocking the main thread
// when scrolling.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => highlight(block, inlines).then(setLines));
} else {
highlight(block, inlines).then(setLines);
}
});
return () => {
canceled = true;
};
}
}, [hasBeenInViewport, block, inlines]);
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
}
@@ -1,291 +1,38 @@
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import type { DocumentBlockCode } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { getNodeFragmentByType } from '@/lib/document';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
import './theme.css';
import { ClientCodeBlock } from './ClientCodeBlock';
import { getInlines, RenderedInline } from './highlight';
import { Blocks } from '../Blocks';
/**
* Render an entire code-block. The syntax highlighting is done server-side.
* Render a code block, can be client-side or server-side.
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
export function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const lines = await highlight(block);
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
'items-center',
'justify-start',
'[grid-area:1/1]',
'text-sm',
'gap-2',
)}
>
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
document={document}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
context={context}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
document: JSONDocument;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
context: DocumentContext;
}) {
const { block, document, line, isLast, withLineNumbers, context } = props;
return (
<span
className={tcls(
'grid',
'[grid-template-columns:subgrid]',
'col-span-2',
'relative',
'ring-1',
'ring-transparent',
'hover:ring-dark-4/5',
'hover:z-[1]',
'dark:hover:ring-light-4/4',
'rounded',
//first child
'[&.highlighted:first-child]:rounded-t-md',
'[&.highlighted:first-child>*]:mt-1',
//last child
'[&.highlighted:last-child]:rounded-b-md',
'[&.highlighted:last-child>*]:mb-1',
//is only child, dont hover effect line
'[&:only-child]:hover:ring-transparent',
//select all highlighted
'[&.highlighted]:rounded-none',
//select first in group
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
//select last in group
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
//select if highlight is singular in group
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
)}
>
{withLineNumbers ? (
<span
className={tcls(
'text-sm',
'text-right',
'pr-3.5',
'rounded-l',
'pl-2',
'sticky',
'left-[-3px]',
'bg-gradient-to-r',
'from-80%',
'from-light-2',
'to-transparent',
'dark:from-dark-2',
'dark:to-transparent',
withLineNumbers
? [
'before:text-dark/5',
'before:content-[counter(line)]',
'[counter-increment:line]',
'dark:before:text-light/4',
line.highlighted
? [
'before:text-dark/6',
'dark:before:text-light/8',
'bg-gradient-to-r',
'from-80%',
'from-light-3',
'to-transparent',
'dark:from-dark-3',
'dark:to-transparent',
]
: null,
]
: [],
)}
></span>
) : null}
<span className={tcls('ml-3', 'block', 'text-sm')}>
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: {
tokens: HighlightToken[];
document: JSONDocument;
context: DocumentContext;
}) {
const { tokens, document, context } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken
const inlines = getInlines(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) {
return null;
}
return (
<Blocks
key={index}
token={token}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
))}
</>
);
}
function CodeHighlightToken(props: {
token: HighlightToken;
document: JSONDocument;
context: DocumentContext;
}) {
const { token, document, context } = props;
if (token.type === 'inline') {
return (
<Inline
inline={token.inline}
document={document}
context={context}
ancestorInlines={[]}
>
<CodeHighlightTokens
tokens={token.children}
document={document}
context={context}
/>
</Inline>
);
}
if (token.type === 'plain') {
return <>{token.content}</>;
}
if (!token.token.color) {
return <>{token.token.content}</>;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
);
})();
return { inline, body };
});
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
}
@@ -0,0 +1,37 @@
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-[1] rounded;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
@apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & {
@apply before:text-tint bg-gradient-to-r from-80% from-tint-active to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -0,0 +1,120 @@
import type { DocumentBlockCode } from '@gitbook/api';
import assertNever from 'assert-never';
import { forwardRef, useId } from 'react';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import './theme.css';
import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
};
/**
* The logic of rendering a code block from lines.
*/
export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const { block, style, lines } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
return (
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
{title ? (
<div className="text-xs tracking-wide text-tint leading-none inline-flex items-center justify-center bg-tint rounded-t straight-corners:rounded-t-s px-3 py-2">
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style="group-hover/codeblock:opacity-[1] transition-opacity duration-75 opacity-0 text-xs [grid-area:2/1] z-[2] justify-self-end backdrop-blur-md leading-none self-start ring-1 ring-tint text-tint bg-transparent rounded-md mr-2 mt-2 p-1 hover:ring-tint-hover"
/>
<pre
className={tcls(
'[grid-area:2/1] relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle hide-scroll',
'rounded-md straight-corners:rounded-sm',
title && 'rounded-ss-none',
)}
>
<code
id={id}
className={tcls(
'min-w-full inline-grid grid-cols-[auto_1fr] p-2 [count-reset:line]',
withWrap && 'whitespace-pre-wrap',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
key={index}
line={line}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
</div>
);
});
function CodeHighlightLine(props: {
line: HighlightLine;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
</span>
</span>
);
}
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
const { tokens } = props;
return tokens.map((token, index) => <CodeHighlightToken key={index} token={token} />);
}
function CodeHighlightToken(props: { token: HighlightToken }) {
const { token } = props;
switch (token.type) {
case 'annotation': {
return (
<AnnotationPopover body={token.body}>
<CodeHighlightTokens tokens={token.children} />
</AnnotationPopover>
);
}
case 'plain': {
return token.content;
}
case 'shiki': {
if (!token.token.color) {
return token.token.content;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
}
default:
assertNever(token);
}
}
@@ -53,7 +53,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}}
block={block}
ancestorBlocks={[]}
isEstimatedOffscreen={false}
// We optimize perf by default
isEstimatedOffscreen
/>
);
}
@@ -1,9 +1,18 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { it, expect } from 'bun:test';
import { highlight } from './highlight';
import { getInlines, highlight, RenderedInline } from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
inline,
body: null,
}));
return highlight(block, inlines);
}
it('should parse plain code', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {},
@@ -38,32 +47,35 @@ it('should parse plain code', async () => {
it('should parse different code in parallel', async () => {
await Promise.all(
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
highlight({
object: 'block',
type: 'code',
data: {
syntax: syntax,
},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
highlight(
{
object: 'block',
type: 'code',
data: {
syntax: syntax,
},
],
}),
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
},
],
},
[],
),
),
);
});
it('should parse a multilines plain code', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {},
@@ -138,7 +150,7 @@ it('should parse a multilines plain code', async () => {
});
it('should parse code with an inline on a single line', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -192,10 +204,8 @@ it('should parse code with an inline on a single line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -229,7 +239,7 @@ it('should parse code with an inline on a single line', async () => {
});
it('should parse code with an inline on a multiple line', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -310,10 +320,8 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -347,10 +355,8 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -384,7 +390,7 @@ it('should parse code with an inline on a multiple line', async () => {
});
it('should support code token finishing before the end of the annotation', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
isVoid: false,
@@ -470,10 +476,8 @@ it('should support code token finishing before the end of the annotation', async
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -507,7 +511,7 @@ it('should support code token finishing before the end of the annotation', async
});
it('should support multiple code tokens in an annotation', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
isVoid: false,
@@ -615,11 +619,8 @@ it('should support multiple code tokens in an annotation', async () => {
},
},
{
type: 'inline',
inline: {
object: 'inline',
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -653,7 +654,7 @@ it('should support multiple code tokens in an annotation', async () => {
});
it('should handle \\r', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -3,16 +3,12 @@ import {
createdBundledHighlighter,
ThemedToken,
createCssVariablesTheme,
HighlighterGeneric,
createSingletonShorthands,
} 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';
import { trace } from '@/lib/tracing';
import { plainHighlight } from './plain-highlight';
export type HighlightLine = {
highlighted: boolean;
@@ -22,34 +18,61 @@ export type HighlightLine = {
export type HighlightToken =
| { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken }
| { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
type InlineIndexed = { inline: any; start: number; end: number };
export type InlineIndexed = { inline: any; start: number; end: number };
type PositionedToken = ThemedToken & { start: number; end: number };
export type RenderedInline = {
inline: InlineIndexed;
body: React.ReactNode;
};
const theme = createCssVariablesTheme();
const highlighter = createSingletonShorthands(
createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
engine: () =>
createJavaScriptRegexEngine({
forgiving: true,
}),
}),
);
/**
* Preload the highlighter for a code block.
*/
export async function preloadHighlight(block: DocumentBlockCode) {
const langName = getBlockLang(block);
if (langName) {
await highlighter.getSingletonHighlighter({
langs: [langName],
themes: [theme],
});
}
}
/**
* Highlight a code block while preserving inline elements.
*/
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
export async function highlight(
block: DocumentBlockCode,
inlines: RenderedInline[],
): Promise<HighlightLine[]> {
const langName = getBlockLang(block);
if (!langName) {
// Language not found, fallback to plain highlighting
return plainHighlighting(block);
return plainHighlight(block, inlines);
}
const inlines: InlineIndexed[] = [];
const code = getPlainCodeBlock(block, inlines);
const code = getPlainCodeBlock(block);
inlines.sort((a, b) => {
return a.start - b.start;
});
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(highlighter, langName);
const lines = highlighter.codeToTokensBase(code, {
const lines = await highlighter.codeToTokensBase(code, {
lang: langName,
theme,
tokenizeMaxLineLength: 400,
});
@@ -75,63 +98,68 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
currentIndex += 1; // for the \n
return {
highlighted: !!lineBlock.data.highlighted,
highlighted: Boolean(lineBlock.data.highlighted),
tokens: result,
};
});
}
/**
* Validate a language name.
* Get the language of a code block.
*/
function getLanguageForSyntax(syntax: string): keyof typeof bundledLanguages | null {
// @ts-ignore
const lang = bundledLanguages[syntax];
if (!lang) {
return null;
}
function getBlockLang(block: DocumentBlockCode): string | null {
return block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
}
// @ts-ignore
return syntax;
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;
}
/**
* Parse a code block without highlighting it.
* Validate a language name.
*/
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = [];
function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
for (const node of lineBlock.nodes) {
if (node.object === 'text') {
tokens.push({
type: 'plain',
content: getNodeText(node),
});
} else {
tokens.push({
type: 'inline',
inline: node,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
});
}
}
// Temporary disable highlighting for C/C++ code blocks
// @see https://github.com/shikijs/shiki/issues/893
if (syntax === 'cpp' || syntax === 'c') {
return null;
}
return {
highlighted: !!lineBlock.data.highlighted,
tokens,
};
});
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
}
// Check if there is a valid alias for the syntax.
const alias = syntaxAliases[syntax];
if (alias && checkIsBundledLanguage(alias)) {
return alias;
}
return null;
}
export function getInlines(block: DocumentBlockCode) {
const inlines: InlineIndexed[] = [];
getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => a.start - b.start);
return inlines;
}
function matchTokenAndInlines(
eat: () => PositionedToken | null,
allInlines: InlineIndexed[],
allInlines: RenderedInline[],
): HighlightToken[] {
const initialToken = eat();
if (!initialToken) {
@@ -139,7 +167,7 @@ function matchTokenAndInlines(
}
const inlines = allInlines.filter(
(inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
);
let token = initialToken;
const result: HighlightToken[] = [];
@@ -155,7 +183,7 @@ function matchTokenAndInlines(
return;
}
const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
if (before) {
result.push({
type: 'shiki',
@@ -170,7 +198,7 @@ function matchTokenAndInlines(
const children: HighlightToken[] = [];
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
while (token.end < inline.end) {
while (token.end < inline.inline.end) {
children.push({
type: 'shiki',
token: token,
@@ -183,7 +211,7 @@ function matchTokenAndInlines(
token = next;
}
const [inside, after] = splitPositionedTokenAt(token, inline.end);
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
if (!inside) {
throw new Error(`expect inside to not be empty`);
}
@@ -194,8 +222,8 @@ function matchTokenAndInlines(
});
result.push({
type: 'inline',
inline: inline.inline,
type: 'annotation',
body: inline.body,
children,
});
@@ -209,11 +237,11 @@ function matchTokenAndInlines(
return result;
}
function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
let content = '';
code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
content += lineContent;
if (index < code.nodes.length - 1) {
@@ -226,8 +254,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): s
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
inlines: InlineIndexed[],
index: number,
inlines?: InlineIndexed[],
): string {
let content = '';
@@ -236,14 +264,16 @@ function getPlainCodeBlockLine(
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, inlines, index + content.length);
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
inlines.push({
inline: node,
start,
end,
});
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
}
}
@@ -295,48 +325,3 @@ function isEmptyPositionedToken(token: PositionedToken): boolean {
function cleanupLine(line: string): string {
return line.replace(/\r/g, '');
}
const createHighlighter = createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
engine: () => createOnigurumaEngine(import('shiki/wasm')),
});
/**
* Load the highlighter, only once, and reuse it.
* It makes sure to handle concurrent calls.
*/
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: [],
});
return highlighter;
});
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<any, any>,
lang: keyof typeof bundledLanguages,
) {
await loadLanguagesMutex.runBlocking(async () => {
if (highlighter.getLoadedLanguages().includes(lang)) {
return;
}
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
);
});
}
@@ -0,0 +1,41 @@
import { DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
*/
export function plainHighlight(
block: DocumentBlockCode,
inlines: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
}
@@ -1,5 +1,5 @@
:root {
--shiki-color-text: theme('colors.dark.DEFAULT/.88');
--shiki-color-text: theme('colors.tint.11');
--shiki-token-constant: #0a6355;
--shiki-token-string: #8b6d32;
--shiki-token-comment: theme('colors.teal.700/.64');
@@ -8,21 +8,21 @@
--shiki-token-function: #8250df;
--shiki-token-string-expression: #6a4906;
--shiki-token-punctuation: theme('colors.pomegranate.700/.92');
--shiki-token-link: theme('colors.dark.DEFAULT');
--shiki-token-link: theme('colors.tint.12');
--shiki-token-inserted: #22863a;
--shiki-token-deleted: #b31d28;
--shiki-token-changed: #8250df;
}
html.dark {
--shiki-color-text: theme('colors.light.DEFAULT/0.88');
--shiki-color-text: theme('colors.tint.11');
--shiki-token-constant: #d19a66;
--shiki-token-string: theme('colors.pomegranate.300');
--shiki-token-comment: theme('colors.teal.300/.64');
--shiki-token-keyword: theme('colors.pomegranate.400');
--shiki-token-parameter: theme('colors.yellow.500');
--shiki-token-function: #56b6c2;
--shiki-token-string-expression: theme('colors.light.4');
--shiki-token-string-expression: theme('colors.tint.11');
--shiki-token-punctuation: #acc6ee;
--shiki-token-link: theme('colors.pomegranate.400');
--shiki-token-inserted: #85e89d;
@@ -7,5 +7,5 @@ import { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props;
return <hr className={tcls(style, 'border-dark/2', 'dark:border-light/2')} />;
return <hr className={tcls(style, 'border-tint-subtle')} />;
}
@@ -1,10 +1,10 @@
import * as gitbookAPI from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script';
import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives';
import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
@@ -13,7 +13,7 @@ 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 nonce = await getContentSecurityPolicyNonce();
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
@@ -22,15 +22,20 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
: getEmbedByUrl(block.data.url));
return (
<Caption {...props}>
<Caption {...props} withBorder>
{embed.type === 'rich' ? (
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
data-visual-test="blackout"
/>
<Script
strategy="lazyOnload"
src="https://cdn.iframe.ly/embed.js"
nonce={nonce}
/>
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
@@ -45,13 +45,10 @@ export function Details(props: {
className={tcls(
className,
'group/expandable',
'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'bg-tint-base',
'border',
'border-b-0',
'border-dark-3/3',
'border-tint-subtle',
//all
'[&]:mt-[0px]',
//select first child
@@ -64,15 +61,6 @@ export function Details(props: {
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */
'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',
'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)}
>
{children}
@@ -36,10 +36,9 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'list-none',
'select-none',
'transition-colors',
'group-hover/expandable:text-dark-4/7',
'group-open:text-dark-3/7',
'dark:group-open:text-light-3/7',
'dark:group-hover/expandable:text-light-3/7',
'text-tint',
'group-hover/expandable:text-tint-strong',
'group-open/expandable:text-tint-strong',
'[&::-webkit-details-marker]:hidden',
)}
>
@@ -52,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'mb-1',
'transition-transform',
'shrink-0',
'group-open:rotate-90',
'group-open/expandable:rotate-90',
)}
/>
<Inlines
@@ -71,7 +70,6 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'right-4',
'flex',
'items-center',
'dark:text-light/3',
'dark:shadow-none',
'dark:ring-0',
)}
@@ -83,8 +81,8 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'size-3',
'transition-colors',
'text-transparent',
'group-hover:text-dark/6',
'dark:group-hover:text-light/5',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong',
)}
/>
</a>
@@ -1,4 +1,4 @@
import { DocumentBlockFile } from '@gitbook/api';
import { DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
@@ -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;
@@ -20,24 +21,18 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
const contentType = getSimplifiedContentType(file.contentType);
return (
<Caption {...props} wrapperStyle={[]}>
<a
<Caption {...props} withBorder>
<Link
href={file.downloadURL}
download={file.name}
className={tcls(
'group/file',
'flex',
'flex-row',
'items-center',
'border',
'px-5',
'py-3',
'border-dark/3',
'rounded-lg',
'hover:text-primary-600',
'dark:border-light/3',
'dark:hover:text-primary-300',
)}
insights={{
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
className={tcls('group/file', 'flex', 'flex-row', 'items-center', 'px-5', 'py-3')}
>
<div
className={tcls(
@@ -49,8 +44,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'items-center',
'gap-1',
'border-r',
'border-dark/2',
'dark:border-light/2',
'border-tint-subtle',
)}
>
<div>
@@ -62,10 +56,8 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
<div
className={tcls(
'text-xs',
'text-dark-4/8',
'group-hover/file:text-dark',
'dark:text-light-4/7',
'dark:group-hover/file:text-light',
'text-tint',
'group-hover/file:text-tint-strong',
)}
>
{getHumanFileSize(file.size)}
@@ -77,7 +69,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
{contentType}
</div>
</div>
</a>
</Link>
</Caption>
);
}
@@ -18,7 +18,10 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
id = context.getId ? context.getId(id) : id;
return (
<Tag id={id} className={tcls(textStyle.textSize, 'group', 'relative', 'grid', style)}>
<Tag
id={id}
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
>
<div
className={tcls(
'grid',
@@ -42,7 +45,6 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
'inline-flex',
'h-full',
'items-start',
'dark:text-light/3',
'dark:shadow-none',
'dark:ring-0',
textStyle.lineHeight,
@@ -55,14 +57,23 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
'h-[1lh]',
'transition-colors',
'text-transparent',
'group-hover:text-dark/6',
'dark:group-hover:text-light/5',
'group-hover:text-tint-subtle',
'contrast-more:group-hover:text-tint-strong',
'lg:w-4',
)}
/>
</a>
</div>
<div className={tcls('grid-area-1-1', 'z-[1]', textStyle.marginTop)}>
<div
className={tcls(
'grid-area-1-1',
'z-[1]',
'justify-self-start',
'text-left',
textStyle.lineHeight,
textStyle.marginTop,
)}
>
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
</div>
</Tag>
@@ -62,28 +62,25 @@ const HINT_STYLES: {
} = {
info: {
icon: 'circle-info',
iconColor: ['text-primary-500', 'dark:text-primary-400'],
bodyColor: [
'[&_a]:text-primary-500',
'[&_a:hover]:text-primary-600',
'dark:[&_a]:text-primary-400',
'dark:[&_a:hover]:text-primary-300',
],
iconColor: ['text-primary'],
bodyColor: ['[&_a]:text-primary', '[&_a:hover]:text-primary-strong'],
style: [
'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',
'bg-tint',
'print-mode:!bg-tint',
'theme-muted:bg-tint-base',
'theme-bold-tint:bg-tint-base',
'theme-gradient:bg-tint-12/1',
'border-tint',
'[&_.can-override-bg]:bg-tint-active',
'[&_.can-override-text]:text-tint-strong',
],
},
warning: {
icon: 'circle-exclamation',
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: [
'text-orange-950',
'dark:text-orange-50',
'[&_a]:text-orange-800',
'[&_a:hover]:text-orange-900',
'dark:[&_a]:text-orange-400',
@@ -100,6 +97,8 @@ const HINT_STYLES: {
icon: 'triangle-exclamation',
iconColor: ['text-red-500', 'dark:text-red-400'],
bodyColor: [
'text-red-950',
'dark:text-red-50',
'[&_a]:text-red-800',
'[&_a:hover]:text-red-900',
'dark:[&_a]:text-red-400',
@@ -115,6 +114,8 @@ const HINT_STYLES: {
icon: 'circle-check',
iconColor: ['text-green-500', 'dark:text-green-400'],
bodyColor: [
'text-green-950',
'dark:text-green-50',
'[&_a]:text-green-800',
'[&_a:hover]:text-green-900',
'dark:[&_a]:text-green-400',
@@ -22,6 +22,7 @@ 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 */
@@ -32,7 +33,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
>
<Image
alt={inline.data.caption ?? ''}
sizes={await getImageSizes(size, src)}
sizes={sizes}
sources={{
light: {
src: src.href,
@@ -1,4 +1,4 @@
import { DocumentInlineLink } from '@gitbook/api';
import { DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { InlineProps } from './Inline';
import { Inlines } from './Inlines';
@@ -25,7 +25,14 @@ 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-subtle hover:text-primary contrast-more:text-primary contrast-more:hover:text-primary-strong transition-colors"
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
@@ -47,10 +47,10 @@
/** Card */
.contentkit-card {
@apply flex flex-col border rounded border-dark/2 dark:border-light/2;
@apply flex flex-col border rounded border-tint-subtle;
}
.contentkit-card.contentkit-card-pressable {
@apply cursor-pointer hover:border-dark/3 hover:dark:border-light/3;
@apply cursor-pointer hover:border-tint-hover;
}
.contentkit-card-header {
@@ -62,7 +62,7 @@
}
.contentkit-card-title {
@apply text-base font-medium text-dark-2 dark:text-light-4;
@apply text-base font-medium text-tint-strong;
}
.contentkit-card-icon {
@@ -88,8 +88,8 @@
/** Buttons */
.contentkit-button {
@apply flex gap-2 items-center justify-center px-2 py-1 rounded
border border-dark/2 dark:border-light/2 hover:border-dark/3 hover:dark:border-light/3
text-dark-2 dark:text-light-4 hover:text-dark-3 hover:dark:text-light-3;
border border-tint hover:border-tint-hover
text-tint hover:text-tint-strong;
}
.contentkit-button .contentkit-icon {
@@ -97,16 +97,16 @@
}
.contentkit-button .contentkit-button-label {
@apply text-sm font-medium text-dark-2 dark:text-light-4;
@apply text-sm font-medium text-tint-strong;
}
/** Modals */
.contentkit-modal-backdrop {
@apply fixed inset-0 z-40 px-7 pb-11 pt-4 md:pt-[min(8vw,_6rem)] flex items-start justify-center backdrop-blur-2xl bg-dark/4 dark:bg-dark/8;
@apply fixed inset-0 z-40 px-7 pb-11 pt-4 md:pt-[min(8vw,_6rem)] flex items-start justify-center backdrop-blur-2xl bg-tint-12/4 dark:bg-tint-1/6;
}
.contentkit-modal {
@apply flex flex-col gap-6 relative z-50 bg-white dark:bg-dark-2
rounded shadow-lg opacity-0 transition-opacity duration-300 border border-dark/2 dark:border-light/2
@apply flex flex-col gap-6 relative z-50 bg-tint
rounded shadow-lg opacity-0 transition-opacity duration-300 border border-tint
w-full max-w-[768px] max-h-[90vh] overflow-y-auto;
}
.contentkit-modal.contentkit-modal-opened {
@@ -118,7 +118,7 @@
}
.contentkit-modal-title {
@apply text-2xl font-medium text-dark-2 dark:text-light-4;
@apply text-2xl font-medium text-tint-strong;
}
.contentkit-modal-body {
@@ -142,12 +142,12 @@
/** Text input */
.contentkit-textinput {
@apply w-full px-2 py-1 rounded border border-dark/2 dark:border-light/2 text-sm text-dark-2 dark:text-light-4;
@apply w-full px-2 py-1 rounded border border-tint text-sm text-tint;
}
/** Dividers */
.contentkit-divider {
@apply border-dark/2 dark:border-light/2;
@apply border-tint-subtle;
}
.contentkit-vstack > .contentkit-divider {
@@ -38,6 +38,8 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
'[&: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"
/>
@@ -135,7 +137,7 @@ function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.R
className={tcls(
textStyle.textSize,
textStyle.lineHeight,
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark/6 dark:text-light/5',
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-tint',
)}
>
{props.children}
@@ -1,4 +1,4 @@
import { DocumentInlineMention } from '@gitbook/api';
import { DocumentInlineMention, SiteInsightsLinkPosition } from '@gitbook/api';
import { StyledLink } from '@/components/primitives';
@@ -15,5 +15,18 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
return null;
}
return <StyledLink href={resolved.href}>{resolved.text}</StyledLink>;
return (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
{resolved.text}
</StyledLink>
);
}
@@ -1,4 +1,4 @@
import { DocumentBlockSwagger } from '@gitbook/api';
import { DocumentBlockOpenAPI } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { OpenAPIOperation } from '@gitbook/react-openapi';
import React from 'react';
@@ -16,18 +16,16 @@ import './scalar.css';
/**
* Render an OpenAPI block.
*/
export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
const { block, style } = props;
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
const { style } = props;
return (
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
<React.Suspense fallback={<OpenAPIFallback />}>
<OpenAPIBody {...props} />
</React.Suspense>
<OpenAPIBody {...props} />
</div>
);
}
async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
async function OpenAPIBody(props: BlockProps<DocumentBlockOpenAPI>) {
const { block, context } = props;
const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef);
@@ -35,7 +33,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
return (
<div className={tcls('hidden')}>
<p>
Error with {error.url}: {error.message}
Error with {error.rootURL}: {error.message}
</p>
</div>
);
@@ -53,6 +51,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
icons: {
chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
plus: <Icon icon="plus" />,
},
CodeBlock: PlainCodeBlock,
defaultInteractiveOpened: context.mode === 'print',
@@ -6,7 +6,7 @@
--scalar-color-1: color-mix(
in srgb,
rgb(var(--tint-color-300, 180 180 180)),
rgb(var(--dark-base, 23 23 23)) 96%
rgb(var(--tint-12, 23 23 23)) 96%
);
--scalar-color-2: color-mix(
in srgb,
@@ -20,7 +20,7 @@
);
--scalar-color-accent: #007d9c;
--scalar-background-1: rgb(var(--light-base, 255 255 255));
--scalar-background-1: rgb(var(--tint-1, 255 255 255));
--scalar-background-2: color-mix(
in srgb,
rgb(var(--tint-color-800, 30 30 30)),
@@ -43,7 +43,7 @@
--scalar-color-green: #0a6355;
--scalar-color-red: #dc1b19;
--scalar-color-yellow: #ffc90d;
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
--scalar-color-blue: rgb(var(--primary-9, 52 109 219));
--scalar-color-orange: #ff8d4d;
--scalar-color-purple: #8250df;
@@ -59,7 +59,7 @@
#eeeeed 0px 0 0 1px;
--scalar-selection-background: rgba(96, 175, 255, 0.4);
--scalar-selection-color: rgb(var(--dark-base, 22 22 22));
--scalar-selection-color: rgb(var(--tint-12, 22 22 22));
}
.dark .scalar-modal-layout,
.dark .scalar-app,
@@ -67,7 +67,7 @@
--scalar-color-1: color-mix(
in srgb,
rgb(var(--tint-color-700, 70 70 70)),
rgb(var(--light-base, 255 255 255)) 100%
rgb(var(--tint-12, 255 255 255)) 100%
);
--scalar-color-2: color-mix(
in srgb,
@@ -81,7 +81,7 @@
);
--scalar-color-accent: #50b7e0;
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
--scalar-background-1: rgb(var(--tint-1, 22 22 22));
--scalar-background-2: color-mix(
in srgb,
rgb(var(--tint-color-200, 200 200 200)),
@@ -103,7 +103,7 @@
--scalar-color-green: #56b6c2;
--scalar-color-red: rgb(245 124 97);
--scalar-color-yellow: #edbe20;
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
--scalar-color-blue: rgb(var(--primary-9, 93 138 226));
--scalar-color-orange: #d19a66;
--scalar-color-purple: #5203d1;
@@ -119,7 +119,7 @@
0 0 0 1px rgba(255, 255, 255, 0.1);
--scalar-selection-background: rgba(96, 175, 255, 0.4);
--scalar-selection-color: rgb(var(--light-base, 255 255 255));
--scalar-selection-color: rgb(var(--tint-12, 255 255 255));
}
.scalar-modal-layout,
.scalar-app,
@@ -263,32 +263,23 @@
}
.scalar-activate {
width: fit-content;
margin: 0px 0.75rem 0.75rem auto;
line-height: 24px;
font-size: 0.75rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.scalar-activate-button {
display: flex;
gap: 6px;
align-items: center;
color: var(--scalar-color-blue);
appearance: none;
outline: none;
border: none;
background: transparent;
}
.scalar-activate-button {
padding: 0 0.5rem;
}
.scalar-activate:hover .scalar-activate-button {
background: var(--scalar-background-3);
border-radius: 3px;
@apply flex gap-1.5 items-center;
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none place-self-start;
@apply ring-1 ring-tint hover:ring-tint-hover;
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:scale-105 active:scale-100 transition-all;
@apply grow-0 shrink-0 truncate;
@apply text-sm px-2.5 py-1;
}
.scalar-app-loading {
@@ -1,99 +1,170 @@
/* Layout Components */
.openapi-operation {
content-visibility: auto;
contain-intrinsic-height: 600px;
@apply flex-1 flex flex-col gap-6;
@apply flex-1 flex flex-col gap-4 mb-14;
}
.openapi-columns {
@apply flex flex-col md:flex-row gap-6 print-mode:flex-col;
@apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch;
}
/** Intro */
/* Introduction */
.openapi-intro {
@apply flex flex-col gap-3 max-w-3xl;
@apply flex flex-col gap-3;
}
.openapi-summary {
@apply flex flex-col items-start justify-start gap-2;
}
.openapi-summary-title {
@apply font-semibold text-xl;
}
.openapi-description.openapi-markdown {
@apply prose-base;
.openapi-summary-title[data-deprecated='true'] {
@apply line-through;
}
/** Method tag */
.openapi-deprecated {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-full text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
.openapi-deprecated-sunset-date {
@apply font-semibold font-mono;
}
.openapi-description.openapi-markdown {
@apply prose-sm text-[0.938rem];
}
.openapi-description.openapi-markdown code {
@apply prose-sm;
}
/* Markdown Elements */
.openapi-markdown blockquote {
@apply !ps-3;
}
.openapi-markdown blockquote > * {
@apply not-italic;
}
.openapi-markdown blockquote > p {
@apply before:content-none after:content-none !font-normal;
}
.openapi-markdown code {
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
.openapi-markdown pre code {
@apply bg-transparent ring-transparent;
}
/* Markdown Base */
.openapi-markdown {
@apply prose text-tint-strong prose-p:!my-2 dark:prose-invert max-w-none whitespace-normal;
}
.openapi-markdown > *:first-child {
@apply !mt-0;
}
.openapi-markdown > *:last-child {
@apply !mb-0;
}
/* Method Tags */
.openapi-method {
@apply rounded font-mono font-bold text-[11.5px] px-1 py-0.5 mr-2 text-dark-1/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-dark-1/1 dark:ring-light-1/1 whitespace-nowrap;
@apply rounded uppercase font-mono font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get {
@apply bg-[hsl(215,54%,86%)] dark:bg-[hsla(215,54%,45%,0.24)] dark:text-[hsl(215,54%,86%)];
/* @apply bg-[hsl(215,54%,86%)] dark:bg-[hsla(215,54%,45%,0.24)] dark:text-[hsl(215,54%,86%)]; */
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post {
@apply bg-[hsl(120,25%,80%)] dark:bg-[hsla(120,54%,32%,0.24)] dark:text-[hsl(120,25%,80%)];
/* @apply bg-[hsl(120,25%,80%)] dark:bg-[hsla(120,54%,32%,0.24)] dark:text-[hsl(120,25%,80%)]; */
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put {
@apply bg-yellow dark:bg-yellow-600/5 dark:text-yellow;
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-teal-300 dark:bg-teal-600/5 dark:text-teal-300;
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete {
@apply bg-pomegranate-200 dark:bg-pomegranate-500/5 dark:text-pomegranate-200;
@apply bg-pomegranate-100 text-pomegranate-800 dark:bg-pomegranate-900 dark:text-pomegranate-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace {
@apply bg-dark-3/4 dark:bg-light-3/5 dark:text-light-3;
@apply bg-tint;
}
/** URL */
/* URL */
.openapi-url {
@apply font-mono text-sm text-dark/8 dark:text-light/8;
@apply font-mono text-sm text-tint;
}
.openapi-url-var {
@apply text-dark/7 underline underline-offset-2 decoration-dark/5 dark:decoration-light/5 dark:text-light/6;
@apply text-tint underline underline-offset-2 decoration-tint-subtle;
}
/** Columns */
/* Column Layout */
.openapi-column-spec {
@apply flex flex-col flex-1 gap-4 max-w-3xl;
@apply flex flex-col gap-8 flex-1;
}
.openapi-column-preview {
@apply flex flex-col flex-1 max-w-3xl;
@apply flex flex-col flex-1;
}
.openapi-column-preview-body {
@apply flex flex-col gap-4 sticky top-4 space-header:top-20 page-api-block:xl:max-2xl:top-32 print-mode:static;
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static;
}
.openapi-column-preview pre {
@apply max-h-60 print-mode:max-h-none;
@apply max-h-96 print-mode:max-h-none;
}
/** Schemas */
/* Schema Structure */
.openapi-schema-root {
@apply mx-3 mb-3 mt-2;
@apply mt-2;
}
.openapi-schema .openapi-schema-properties {
@apply border rounded border-dark/2 dark:border-light/2;
.openapi-schema-properties {
@apply flex flex-col;
}
.openapi-schema-properties .openapi-schema {
@apply border-b px-3 py-2 border-dark/1 dark:border-light/1;
.openapi-schema-body {
@apply py-2.5 flex flex-col gap-2;
}
.openapi-section-body .openapi-schema-properties {
@apply divide-y divide-tint-subtle;
}
.openapi-disclosure-group-panel
> .openapi-schema-properties
> *:first-child
> .openapi-schema-body {
@apply pt-0;
}
.openapi-responsebody > .openapi-schema-properties > *:last-child > .openapi-schema-body {
@apply pb-0;
}
.openapi-responsebody > .openapi-schema-properties > *:only-child > .openapi-schema-body {
@apply py-0;
}
.openapi-schema-properties .openapi-schema:last-child {
@@ -108,28 +179,46 @@
@apply mt-3;
}
/* Schema Presentation */
.openapi-schema-presentation {
@apply flex flex-col gap-1.5 font-normal;
}
.openapi-schema-properties:last-child {
@apply pb-0;
}
.openapi-schema-name {
/* To make double click on the property name select only the name,
we disable selection on the parent and re-enable it on the children. */
@apply select-none;
@apply select-none flex gap-2.5 items-baseline text-sm;
}
.openapi-schema-name .openapi-deprecated {
@apply text-xs;
}
.openapi-schema-propertyname {
@apply select-all font-medium text-base text-dark/10 dark:text-light/10;
@apply select-all font-mono font-normal text-tint-strong;
}
.openapi-schema-propertyname[data-deprecated='true'] {
@apply line-through opacity-9;
}
.openapi-schema-required {
@apply font-normal text-yellow-600;
@apply text-amber-600 text-[0.813rem];
}
.openapi-schema-type {
@apply font-mono ml-3 text-base text-dark-4/8 dark:text-light-4 select-text;
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
}
.openapi-schema-type:only-child {
@apply ml-0;
}
/* Schema Header */
.openapi-schema-header {
@apply flex flex-row items-center;
}
@@ -138,12 +227,8 @@
@apply flex-1;
}
.openapi-schema-body {
@apply mt-2;
}
.openapi-schema-circular {
@apply text-xs text-dark/6 dark:text-light/6;
@apply text-xs text-tint;
}
.openapi-schema-circular a {
@@ -154,12 +239,13 @@
@apply text-base;
}
/* Schema Enum */
.openapi-schema-enum {
@apply flex flex-row flex-wrap mt-1;
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
}
.openapi-schema-enum-value {
@apply font-mono text-xs px-1.5 py-1 border border-light-4 ml-[-1px] mt-[-1px] text-dark/8 dark:text-light/8 dark:border-dark-4;
@apply text-sm;
}
.openapi-schema-enum-value:first-child {
@@ -170,112 +256,118 @@
@apply rounded-r;
}
/* Schema Description */
.openapi-schema-description.openapi-markdown {
@apply prose-sm;
@apply prose-sm text-tint overflow-hidden !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
}
.openapi-schema-description.openapi-markdown pre:has(code) {
@apply overflow-x-auto flex-1 w-fit max-w-full h-fit whitespace-pre;
scrollbar-width: none;
-ms-overflow-style: none;
}
.openapi-schema-description.openapi-markdown pre::-webkit-scrollbar {
display: none;
}
.openapi-schema-description.openapi-markdown pre code {
font-weight: inherit !important;
@apply text-xs !py-0;
}
/* Schema Examples */
.openapi-schema-example,
.openapi-schema-pattern {
@apply prose-sm mt-2 text-dark/10 dark:text-light/10;
@apply prose-sm text-tint;
}
/** Authentication */
.openapi-securities {
@apply border rounded border-dark/2 dark:border-light/2;
.openapi-schema-example code,
.openapi-schema-pattern code,
.openapi-schema-enum-value code {
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
/* Authentication */
.openapi-securities-header {
@apply px-3 py-2;
}
.openapi-securities.openapi-securities-closed:hover {
@apply border-dark/3 dark:border-light/3 cursor-pointer;
}
.openapi-securities-header-content {
@apply font-semibold text-base text-dark-2 dark:text-light-4;
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
}
.openapi-securities-body {
@apply mx-3 mb-2 flex flex-col gap-2;
@apply flex flex-col gap-2;
}
.openapi-securities-description.openapi-markdown {
@apply prose-sm;
@apply prose-sm text-tint !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
}
.openapi-securities-label {
@apply font-medium text-sm text-dark-2 dark:text-light-4;
}
/** Parameters */
.openapi-parameters {
@apply border rounded border-dark/2 dark:border-light/2;
}
.openapi-parameters.openapi-parameters-closed:hover {
@apply border-dark/3 dark:border-light/3 cursor-pointer;
@apply font-medium text-sm text-tint-strong;
}
/* Parameters */
.openapi-parameters-header {
@apply px-3 py-2;
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
}
.openapi-parameters-header-content {
@apply font-semibold text-base text-dark-2 dark:text-light-4;
}
/** Request body */
.openapi-requestbody {
@apply border rounded border-dark/2 dark:border-light/2;
}
/* Request Body */
.openapi-requestbody-header {
@apply px-3 py-2;
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
}
.openapi-requestbody-header-content {
@apply font-semibold text-base text-dark-2 dark:text-light-4;
}
.openapi-requestbody-description.openapi-markdown {
@apply px-3 prose-sm;
}
.openapi-requestbody-description.openapi-markdown {
@apply px-3 prose-sm;
}
.openapi-requestbody-description.openapi-markdown {
@apply px-3 prose-sm;
}
/** Responses */
.openapi-responses {
@apply border rounded border-dark/2 dark:border-light/2;
@apply prose-sm text-tint !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
}
/* Responses */
.openapi-responses-header {
@apply px-3 py-2;
}
.openapi-responses-body {
@apply flex flex-col gap-3;
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
}
.openapi-responses-header-content {
@apply font-semibold text-dark-2 dark:text-light-4;
}
/** Response */
.openapi-response-tab-content {
@apply overflow-hidden max-w-full flex items-baseline gap-2;
}
.openapi-response-description.openapi-markdown {
@apply px-3 prose-sm;
@apply text-left prose-sm text-[0.813rem] h-auto relative leading-[1.125rem] text-tint !font-normal truncate select-text prose-strong:font-semibold prose-strong:text-inherit;
}
.openapi-response-description:last-child {
@apply mb-3;
.openapi-response-description.openapi-markdown::-webkit-scrollbar {
display: none;
}
.openapi-response-description p {
@apply truncate max-w-full inline pr-1;
}
.openapi-response-statuscode {
@apply tabular-nums text-sm font-normal font-mono shrink-0;
}
.openapi-response-content-type {
@apply text-xs text-tint-8 ml-auto shrink-0;
}
.openapi-response-body {
@apply flex flex-col gap-3;
}
/* Response Body and Headers */
.openapi-responsebody .openapi-schema-name {
@apply mb-0;
}
.openapi-responsebody .openapi-schema-presentation {
@apply py-0;
}
.openapi-responsebody-header,
@@ -285,82 +377,244 @@
.openapi-responsebody-header-content,
.openapi-responseheaders-header-content {
@apply text-base text-dark/8 dark:text-light/8;
}
/** Code sample */
/* Code Sample */
.openapi-codesample {
@apply border rounded bg-light-2 border-dark/1 dark:bg-dark-2 dark:border-light/2;
@apply border rounded bg-tint border-tint-subtle;
}
.openapi-codesample-header {
@apply flex flex-row items-center px-3 py-2;
@apply flex flex-row items-center;
}
.openapi-codesample-title {
@apply flex-1 font-semibold text-base;
.openapi-codesample-header-content {
@apply flex flex-row items-center h-fit;
}
/** Response example */
/* Path */
.openapi-path {
@apply flex items-center bg-tint-base text-sm gap-2 p-2 border rounded-md border-tint-subtle;
}
.openapi-path .openapi-method {
@apply text-[0.813rem] m-0 px-1;
}
.openapi-path-title {
@apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong;
scrollbar-width: none;
-ms-overflow-style: none;
}
.openapi-path-title[data-deprecated='true'] {
@apply line-through;
}
.openapi-path-title::-webkit-scrollbar {
display: none;
}
/* .openapi-path-copy {
@apply absolute opacity-0 h-fit right-0 top-1/2 -translate-y-1/2 bg-light dark:bg-dark border rounded-md border-tint-subtle px-1.5 py-0;
}
.openapi-path-title:hover .openapi-path-copy {
@apply opacity-11;
} */
.openapi-path-title em {
@apply not-italic text-primary font-medium;
}
.openapi-path-separator {
@apply text-tint;
}
.openapi-path-overlay {
@apply flex flex-row items-center py-2 px-3 justify-end border-t border-tint-subtle;
}
/* Response Example */
.openapi-response-example {
@apply border rounded bg-light-2 border-dark/1 dark:bg-dark-2 dark:border-light/2;
@apply border rounded bg-tint border-tint-subtle;
}
.openapi-response-example-header {
@apply flex flex-row items-center px-3 py-2;
.openapi-response-example-empty {
@apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center;
}
.openapi-response-example-title {
@apply flex-1 font-semibold text-base;
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-tint-subtle bg-tint;
}
/** Common */
.openapi-select {
@apply max-w-48 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-dark-2/2 bg-light-2 dark:bg-dark-2 dark:border-light/1;
}
/** Section */
.openapi-section {
min-width: fit-content;
max-width: fit-content;
}
.openapi-select:focus {
width: auto;
}
/* Section Components */
.openapi-section-header {
@apply flex flex-row items-start;
@apply flex flex-row items-center;
}
.openapi-section-header-content {
@apply flex-1;
@apply flex-1 text-base gap-1.5 flex w-full font-medium text-tint-strong;
}
.openapi-section-header-controls {
@apply flex flex-row items-center gap-2;
}
.openapi-section-toggle {
@apply flex items-center justify-end size-6 text-dark/6 dark:text-light/6;
.openapi-section-header-content:has(.openapi-section-toggle) {
@apply cursor-pointer;
}
.openapi-section-header-content:hover .openapi-section-toggle {
@apply text-tint;
}
.openapi-section-toggle {
@apply text-tint-subtle contrast-more:text-tint-strong;
}
.openapi-section-toggle > svg {
@apply transition-all;
}
.openapi-section-toggle[aria-expanded='true'] > svg {
@apply rotate-90;
}
.openapi-section-toggle:focus-visible,
.openapi-section-toggle:focus-visible {
@apply text-primary-500;
@apply text-primary;
}
.openapi-section-toggle svg {
@apply size-3;
}
/** Markdown */
.openapi-markdown {
@apply prose dark:prose-invert max-w-none whitespace-normal;
/* Tabs */
.openapi-tabs-list {
@apply flex flex-row gap-3 py-1.5 px-2.5 border-b border-tint-subtle w-full;
}
.openapi-markdown > *:first-child {
@apply mt-0;
.openapi-tabs-tab {
@apply hover:bg-primary-hover font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded;
}
.openapi-markdown > *:last-child {
@apply mb-0;
.openapi-tabs-tab[aria-selected='true'] {
@apply text-primary after:absolute after:-bottom-2 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel {
@apply flex-1 text-sm focus-visible:outline-none;
}
.openapi-tabs-footer {
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
}
/* Disclosure group */
.openapi-disclosure-group {
@apply border-b border-tint-subtle relative;
}
.openapi-disclosure-group-header {
@apply flex flex-row items-baseline justify-between gap-3 relative;
}
.openapi-disclosure-group-trigger {
@apply flex items-baseline relative flex-1 gap-2.5 py-2 truncate -outline-offset-1;
}
.openapi-disclosure-group-trigger:disabled {
@apply cursor-default;
}
.openapi-disclosure-group:only-child,
.openapi-disclosure-group:last-child {
@apply border-b-0;
}
.openapi-disclosure-group-trigger:disabled .openapi-disclosure-group-icon {
@apply invisible;
}
.openapi-disclosure-group-trigger[aria-expanded='true'] .openapi-response-description {
@apply whitespace-normal;
}
.openapi-disclosure-group-icon > svg {
@apply size-3 text-tint-subtle transition-all duration-300;
}
.openapi-disclosure-group-trigger:hover > .openapi-disclosure-group-icon svg {
@apply text-tint-strong;
}
.openapi-disclosure-group-panel {
@apply pb-2.5;
}
.openapi-disclosure-group-trigger[aria-expanded='true'] > .openapi-disclosure-group-icon > svg {
@apply rotate-90;
}
.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype {
@apply opacity-11 flex;
}
.openapi-disclosure-group-mediatype {
@apply opacity-0 hidden text-xs transition-all duration-200 shrink-0 absolute right-0 top-2.5;
}
.openapi-disclosure-group-mediatype > span {
@apply px-1 bg-tint-6 text-tint-12 rounded-full;
}
/* Disclosure */
.openapi-disclosure-trigger {
@apply transition-all duration-300 hover:text-tint-strong rounded-2xl border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1;
}
.openapi-disclosure svg {
@apply size-3 transition-transform duration-300;
}
.openapi-disclosure-trigger[aria-expanded='true'] svg {
@apply rotate-45;
}
.openapi-disclosure-trigger[aria-expanded='true'] {
@apply w-full rounded-lg border-b rounded-b-none;
}
.openapi-disclosure-trigger[aria-expanded='false'] {
@apply w-auto;
}
.openapi-disclosure-panel[aria-hidden='false'] {
@apply border-b border-x border-tint-subtle rounded-b-lg;
}
.openapi-disclosure-panel .openapi-schema-body {
@apply p-2.5;
}
.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child,
.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child .openapi-schema-name {
@apply !m-0;
}
.openapi-disclosure .openapi-schema-properties .openapi-schema-enum {
@apply pt-0 mt-0;
}
.openapi-section-body.openapi-schema.openapi-schema-root-body {
@apply space-y-2.5;
}
@@ -12,17 +12,7 @@ export function Quote(props: BlockProps<DocumentBlockQuote>) {
tag="blockquote"
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[
style,
'text-dark/7',
'border-l-2',
'pl-6',
'py-3',
'border-dark/2',
'dark:text-light/7',
'dark:border-light/2',
'space-y-4',
]}
style={[style, 'text-tint', 'border-l-2', 'pl-6', 'py-3', 'border-tint', 'space-y-4']}
/>
);
}
@@ -34,13 +34,13 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
<div className="relative select-none">
<div
className={tcls(
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900 tabular-nums',
'font-medium text-primary-800 dark:text-primary-200',
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-subtle tabular-nums',
'font-medium text-primary',
)}
>
{index + 1}
</div>
<div className="absolute bottom-2 left-[0.875rem] top-9 w-px bg-primary-50 dark:bg-primary-900 can-override-bg" />
<div className="absolute bottom-2 left-[0.875rem] top-9 w-px bg-primary-subtle can-override-bg" />
</div>
<Blocks
{...contextProps}

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