Compare commits

...

205 Commits

Author SHA1 Message Date
Samy Pessé 80d2733c25 Version Packages (#2859)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-26 13:41:20 +01:00
Samy Pessé feb6735a4d Abstract data fetching between v1 and v2 (#2865) 2025-02-26 13:20:16 +01:00
Nolann B. f1d1d2fd81 Return empty string if no server provided (#2878) 2025-02-26 09:13:27 +01:00
Greg Bergé a749c67803 Extract and remove unused code around OpenAPI servers (#2867) 2025-02-25 17:39:27 +01:00
Nolann B. c808bb1483 Fallback to untrusted validation in case JSON OpenAPI spec is invalid (#2877) 2025-02-25 17:27:31 +01:00
Greg Bergé e24206ebf0 Remove dependency to swagger2openapi and yaml (#2874) 2025-02-25 15:42:11 +01:00
Greg Bergé 3410785a3a Remove .tsbuildinfo (#2875) 2025-02-25 15:24:12 +01:00
Nolann B. b4a12d606e Fix circularRef in schema + examples OpenAPI (#2872) 2025-02-24 21:26:26 +01:00
Greg Bergé a0545545d7 Faster OpenAPI spec validation (#2871) 2025-02-24 21:21:27 +01:00
Zeno Kapitein 1f11650ca1 Bump hint icon up 1px (#2869) 2025-02-24 16:21:19 +01:00
Greg Bergé da55facf2c Support GitBook blocks in OpenAPI operation description (#2868) 2025-02-24 13:44:48 +01:00
Nolann B. dc2dbc5710 Update OpenAPI code examples to support multiple content-type (#2843) 2025-02-24 13:07:35 +01:00
Nolann B. bdd6303bcc Rearrange item types in OpenAPI blocks (#2862) 2025-02-24 11:33:47 +01:00
Zeno Kapitein 66d0fc0683 Update hint block (#2840)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-02-21 15:32:35 +01:00
Nolann B. 05e1d8cd96 Hide x-gitbook-* symbols in OpenAPI blocks (#2863) 2025-02-21 12:38:55 +01:00
Greg Bergé 9f0de74caa Add support for new OpenAPI ref (#2860) 2025-02-20 14:31:04 +01:00
spastorelli a820739bd2 Remove ununsed search api lib methods (#2855) 2025-02-20 09:46:12 +01:00
Samy Pessé 304042017c Version Packages (#2847)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-20 08:36:09 +01:00
Greg Bergé 82cd9f2979 Add support for anchor link in OpenAPI blocks (#2858) 2025-02-19 23:38:20 +01:00
Greg Bergé a3f1fea27b Display OpenAPI header description (#2857) 2025-02-19 23:34:52 +01:00
Greg Bergé bb5c6a42e7 Support multiple examples and multiple responses example (#2856) 2025-02-19 20:39:15 +01:00
Valentino Hudhra 445baaaa61 Add a new packages @gitbook/colors (#2854)
Co-authored-by: Zeno Kapitein <zenomilan@me.com>
2025-02-19 17:17:39 +01:00
Nolann B. 7419ee7dba Show additional fields in OpenAPI block (#2851) 2025-02-19 17:01:15 +01:00
Nolann B. aa6be381a4 Make OpenAPI tabs list scrollable if overflowing (#2848) 2025-02-19 11:10:11 +01:00
Greg Bergé 61575838f3 Optimize markdown parsing (#2850) 2025-02-19 10:29:33 +01:00
Samy Pessé 359bb979f8 Open external link in a new tab when embedded in an iframe (#2846) 2025-02-18 12:41:45 +00:00
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
374 changed files with 80046 additions and 8316 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'
+194 -47
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy: deploy:
name: Deploy to Cloudflare Pages name: Deploy to Cloudflare Pages
@@ -18,14 +18,13 @@ jobs:
checks: write checks: write
statuses: write statuses: write
outputs: 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -40,15 +39,17 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main') if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages - name: Build Next.js with next-on-pages
run: bun run build:cloudflare run: bun run turbo gitbook#build:cloudflare
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }} SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy - id: deploy
name: Deploy to Cloudflare name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.11.0 uses: cloudflare/wrangler-action@v3.14.0
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -74,26 +75,161 @@ jobs:
-H "Accept: application/vnd.github.v3+json" \ -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"}' \ -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 }} 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_URL=https://open-2v.gitbook.com" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static-2v.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 --target=$VERCEL_ENVIRONMENT $([ "$VERCEL_ENVIRONMENT" = "production" ] && echo "--prod") --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_URL=https://open-2c.gitbook.com" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static-2c.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.14.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 - name: Find GitHub Comment
uses: peter-evans/find-comment@v3 uses: peter-evans/find-comment@v3
id: fc id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with: with:
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]' comment-author: 'github-actions[bot]'
body-includes: GitBook Preview body-includes: 'Summary of the deployments'
- name: Create or update GitHub comment - name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4 uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with: with:
comment-id: ${{ steps.fc.outputs.comment-id }} comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }}
body: | body: |
**GitBook Preview** Summary of the deployments:
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 }}) ### 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 }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/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 }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace edit-mode: replace
visual-testing: visual-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -102,10 +238,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Setup Playwright - name: Setup Playwright
@@ -113,7 +247,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: bun e2e run: bun e2e
env: env:
BASE_URL: ${{needs.deploy.outputs.deployment_url}} BASE_URL: ${{needs.deploy.outputs.deployment-url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@@ -121,6 +255,31 @@ jobs:
name: playwright-test-results name: playwright-test-results
path: packages/gitbook/test-results/ path: packages/gitbook/test-results/
retention-days: 3 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: pagespeed-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: PageSpeed Testing name: PageSpeed Testing
@@ -128,10 +287,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -139,7 +296,7 @@ jobs:
- name: Run pagespeed tests - name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env: env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}} DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }} PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -147,10 +304,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -162,10 +317,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -177,10 +330,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -191,16 +342,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Build (Open Source) name: Build (Open Source)
env: env:
NPMRC_FONT_AWESOME_TOKEN: '' NPM_TOKEN_READONLY: ''
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build - run: bun run build
@@ -210,10 +359,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
+4 -8
View File
@@ -17,10 +17,8 @@ jobs:
with: with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0 fetch-depth: 0
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -45,10 +43,8 @@ jobs:
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: ./.github/composite/setup-bun
with:
bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
+3
View File
@@ -22,3 +22,6 @@ yarn-error.log*
# Env files # Env files
.env.local .env.local
# TypeScript
*.tsbuildinfo
+7
View File
@@ -1,5 +1,6 @@
.next .next
.vercel .vercel
.open-next
# Generated # Generated
packages/emoji-codepoints/index.ts packages/emoji-codepoints/index.ts
@@ -8,3 +9,9 @@ packages/icons/src/data/*.json
# Build files # Build files
dist/ dist/
README.md
CHANGELOG.md
# v2
packages/gitbook-v2/public/~gitbook/static/
+24 -2
View File
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >=18.x) - Node.js (Version: >=20.6)
- Bun - 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 ### 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. 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 ## 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. 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.
+5871
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] [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", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.7", "@changesets/cli": "^2.27.12",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"turbo": "^2.1.2" "turbo": "^2.4.1-canary.2",
"vercel": "^39.3.0"
}, },
"packageManager": "bun@1.1.18", "packageManager": "bun@1.1.18",
"patchedDependencies": { "overrides": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch", "@codemirror/state": "6.4.1",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch" "react": "18.3.1",
"react-dom": "18.3.1"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build", "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": "turbo run lint",
"lint:fix": "turbo run lint -- --fix", "lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
@@ -23,6 +27,7 @@
"format:check": "prettier ./ --ignore-unknown --list-different", "format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit", "unit": "turbo run unit",
"e2e": "turbo run e2e", "e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"release": "turbo run release && changeset publish", "release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview", "release:preview": "turbo run release:preview",
@@ -31,5 +36,9 @@
}, },
"workspaces": [ "workspaces": [
"packages/*" "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"
}
} }
+1 -1
View File
@@ -21,7 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3", "typescript": "^5.5.3",
"wrangler": "3.82.0" "wrangler": "^3.109.2"
}, },
"scripts": { "scripts": {
"generate": "wrangler types --experimental-include-runtime", "generate": "wrangler types --experimental-include-runtime",
+1
View File
@@ -0,0 +1 @@
dist/
+7
View File
@@ -0,0 +1,7 @@
# @gitbook/colors
## 0.2.0
### Minor Changes
- 445baaa: Initial release
+3
View File
@@ -0,0 +1,3 @@
# `@gitbook/colors`
A set of default colors and transformation functions used throughout the GitBook Open and app.
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@gitbook/colors",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": [
"dist",
"src",
"README.md",
"CHANGELOG.md"
]
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Default primary color throughout the GitBook ecosystem.
*/
export const DEFAULT_PRIMARY_COLOR = '#346DDB';
/**
* The darkest color that exists in GitBook, used as the relative minimum of every generated color scale.
*/
export const DARK_BASE = '#1D1D1D';
/**
* The lightest color that exists in GitBook, used as the relative maximum of every generated color scale.
*/
export const LIGHT_BASE = '#FFFFFF';
/**
* Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization.
*/
export const DEFAULT_TINT_COLOR = '#787878';
/**
* Used for informational messages and neutral alerts.
*/
export const DEFAULT_HINT_INFO_COLOR = '#787878';
/**
* Used for showing important information or non-critical warnings.
*/
export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00';
/**
* Used for destructive actions or raising attention to critical information.
*/
export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36';
/**
* Used for showing positive actions or achievements.
*/
export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950';
+2
View File
@@ -0,0 +1,2 @@
export * from './colors';
export * from './transformations';
+408
View File
@@ -0,0 +1,408 @@
import { DARK_BASE, LIGHT_BASE, DEFAULT_TINT_COLOR } from './colors';
type ColorShades = {
[key: string]: string;
};
type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
components = 'components',
borders = 'borders',
accents = 'accents',
text = 'text',
}
type ColorSubScale = {
[key: string]: number;
};
/**
* Main color scale object.
*
* Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation.
* Each subitem maps a semantic name within that category to a step in the scale.
*/
export const scale: Record<ColorCategory, ColorSubScale> = {
[ColorCategory.backgrounds]: {
/** Base background */
base: 1,
/** Accent background */
subtle: 2,
},
[ColorCategory.components]: {
/** Component background */
DEFAULT: 3,
/** Component hover background */
hover: 4,
/** Component active background */
active: 5,
},
[ColorCategory.borders]: {
/** Subtle borders, separators */
subtle: 6,
/** Element border, focus rings */
DEFAULT: 7,
/** Element hover border */
hover: 8,
},
[ColorCategory.accents]: {
/** Solid backgrounds */
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
},
[ColorCategory.text]: {
/** Very low-contrast text
* Caution: this contrast does not meet accessiblity guidelines.
* Always check if you need to include a mitigating contrast-more style for users who need it. */
subtle: 9,
/** Low-contrast text */
DEFAULT: 11,
/** High-contrast text */
strong: 12,
},
};
/**
* The mix of foreground and background for every step in a colour scale.
* 0: 100% of the background color's luminosity, white in light mode
* 1: 100% of the foreground color's luminosity, black in light mode
*/
export const colorMixMapping = {
// bgs |components |borders |solid |text
light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1],
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
};
/**
* Convert a hex color to an RGB color.
*/
export function hexToRgb(hex: string): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGB values separated by spaces
return `${r} ${g} ${b}`;
}
/**
* Convert a hex color to a RGBA color.
*/
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGBA values separated by spaces
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Generate Tailwind-compatible shades from a single color
* @param {string} hex The hex code to generate shades from
* @param {boolean} halfShades Generate additional shades, e.g. at 150
* @returns {{[key: number]: string}}
*/
export function shadesOfColor(hex: string, halfShades = false) {
const baseColor = hex;
const shades = [
50,
100,
200,
300,
400,
500,
600,
700,
800,
900,
...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []),
].sort();
const result: ColorShades = {};
for (const shade of shades) {
const key = shade.toString();
if (shade === 500) {
result[key] = hex;
continue;
}
let shadeIndex = shade;
const isDarkShade = shadeIndex > 500;
if (isDarkShade) {
shadeIndex -= 500;
}
const percentage = shadeIndex / 500;
const startColor = isDarkShade ? DARK_BASE : baseColor;
const endColor = isDarkShade ? baseColor : LIGHT_BASE;
result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor));
}
return result;
}
export type ColorScaleOptions = {
/** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */
darkMode?: boolean;
/** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
background?: string;
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
foreground?: string;
mix?: {
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
color: string;
/** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */
ratio: number;
};
};
/**
* Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour.
* @param {string} hex The hex code to generate shades from
* @param {object} options
*/
export function colorScale(
hex: string,
{
darkMode = false,
background = darkMode ? DARK_BASE : LIGHT_BASE,
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
mix,
}: ColorScaleOptions = {},
) {
const baseColor = rgbToOklch(hexToRgbArray(hex));
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
const backgroundColor = rgbToOklch(hexToRgbArray(background));
if (mixColor && mix?.ratio && mix.ratio > 0) {
// If defined, we mix in a (tiny) bit of the mix color with the base color.
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
}
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
const result = [];
for (let index = 0; index < mapping.length; index++) {
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
// Original colour is close enough to target, so let's use the original colour as step 9.
result.push(hex);
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
C: baseColor.C * chromaRatio,
H: baseColor.H, // Maintain the hue from the base color
};
const newHex = rgbArrayToHex(oklchToRgb(shade));
result.push(newHex);
}
return result;
}
/**
* Convert a hex color to an RGB color set.
*/
export function hexToRgbArray(hex: string): RGBColor {
const originalHex = hex;
let value = hex.replace('#', '');
if (hex.length === 3) value = value + value;
const r = value.substring(0, 2);
const g = value.substring(2, 4);
const b = value.substring(4, 6);
const rgb = [r, g, b].map((channel) => {
try {
const channelInt = Number.parseInt(channel, 16);
if (channelInt < 0 || channelInt > 255) throw new Error();
return channelInt;
} catch {
throw new Error(`Invalid hex color provided: ${originalHex}`);
}
});
return rgb as RGBColor;
}
/**
* Convert a RGB color set to a hex color.
*/
export function rgbArrayToHex(rgb: RGBColor): string {
return `#${rgb
.map((channel) => {
const component = channel.toString(16);
if (component.length === 1) return `0${component}`;
return component;
})
.join('')}`;
}
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel));
});
return rgbArrayToHex(rgb as RGBColor);
}
// Utility constants and helper functions
export function rgbToLinear(rgb: RGBColor): [number, number, number] {
return rgb.map((v) => {
const scaled = v / 255;
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
}
export function linearToRgb(linear: [number, number, number]): RGBColor {
return linear.map((v) => {
const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(Math.max(0, Math.min(1, scaled)) * 255);
}) as RGBColor;
}
export function rgbToOklab(rgb: RGBColor): OKLABColor {
const [r, g, b] = rgbToLinear(rgb);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
return {
L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
};
}
export function oklabToRgb(oklab: OKLABColor): RGBColor {
const { L, A, B } = oklab;
const lRoot = L + 0.3963377774 * A + 0.2158037573 * B;
const mRoot = L - 0.1055613458 * A - 0.0638541728 * B;
const sRoot = L - 0.0894841775 * A - 1.291485548 * B;
const l = lRoot ** 3;
const m = mRoot ** 3;
const s = sRoot ** 3;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return linearToRgb([r, g, b]);
}
export function oklabToOklch(oklab: OKLABColor): OKLCHColor {
const { L, A, B } = oklab;
const C = Math.sqrt(A ** 2 + B ** 2);
const H = (Math.atan2(B, A) * 180) / Math.PI;
return { L, C, H: H < 0 ? H + 360 : H };
}
export function oklchToOklab(oklch: OKLCHColor): OKLABColor {
const { L, C, H } = oklch;
const rad = (H * Math.PI) / 180;
return {
L,
A: C * Math.cos(rad),
B: C * Math.sin(rad),
};
}
export function rgbToOklch(rgb: RGBColor): OKLCHColor {
return oklabToOklch(rgbToOklab(rgb));
}
export function oklchToRgb(oklch: OKLCHColor): RGBColor {
return oklabToRgb(oklchToOklab(oklch));
}
export function rgbToXyz(rgb: RGBColor): [number, number, number] {
const [r, g, b] = rgbToLinear(rgb);
return [
(r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
(r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
(r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
];
}
export function xyzToLab65(xyz: [number, number, number]): {
L: number;
A: number;
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i];
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
});
return {
L: 116 * y - 16,
A: 500 * (x - y),
B: 200 * (y - z),
};
}
export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } {
return xyzToLab65(rgbToXyz(rgb));
}
/*
Delta Phi Star perceptual lightness contrast by Andrew Somers:
https://github.com/Myndex/deltaphistar
*/
export const PHI = 0.5 + Math.sqrt(1.25);
export function dpsContrast(a: RGBColor, b: RGBColor) {
const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI);
const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40;
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
color: undefined,
contrast: 0,
};
for (const color of foreground) {
const c = hexToRgbArray(color);
const contrast = dpsContrast(c, bg);
if (contrast > best.contrast) {
best.color = c;
best.contrast = contrast;
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0];
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+11
View File
@@ -0,0 +1,11 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
# Symbolic links
public
+7
View File
@@ -0,0 +1,7 @@
# gitbook-v2
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
View File
+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.
+33
View File
@@ -0,0 +1,33 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
GITBOOK_URL: process.env.GITBOOK_URL,
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,
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
};
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;
+29
View File
@@ -0,0 +1,29 @@
{
"name": "gitbook-v2",
"version": "0.1.0",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "^0.96.0",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.3",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
"scripts": {
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
"dev:v2": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,5 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
@@ -0,0 +1,5 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -0,0 +1,38 @@
import { getDynamicSiteContext, getPagePathFromParams, RouteParams } from '@v2/app/utils';
import {
generateSitePageMetadata,
generateSitePageViewport,
SitePage,
} from '@/components/SitePage';
import { Metadata, Viewport } from 'next';
type PageProps = {
params: Promise<RouteParams>;
searchParams: Promise<{ fallback?: string }>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getDynamicSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
fallback: !!searchParams.fallback,
});
}
@@ -0,0 +1,28 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayout } from '@/components/SiteLayout';
import { getDynamicSiteContext, RouteLayoutParams } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
export default async function SiteDynamicLayout({
params,
children,
}: {
params: Promise<RouteLayoutParams>;
children: React.ReactNode;
}) {
const context = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
@@ -0,0 +1,5 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
@@ -0,0 +1,5 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -0,0 +1,44 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { getSiteCacheTag } from '@v2/lib/cache';
import { getPagePathFromParams, getStaticSiteContext, RouteParams } from '@v2/app/utils';
import {
generateSitePageMetadata,
generateSitePageViewport,
SitePage,
} from '@/components/SitePage';
import { Metadata, Viewport } from 'next';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(getSiteCacheTag(context.site.id));
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getStaticSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
});
}
@@ -0,0 +1,28 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { getSiteCacheTag } from '@v2/lib/cache';
import { getStaticSiteContext, RouteLayoutParams } from '@v2/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayout } from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
export default async function SiteStaticLayout({
params,
children,
}: {
params: Promise<RouteLayoutParams>;
children: React.ReactNode;
}) {
'use cache';
const context = await getStaticSiteContext(await params);
cacheTag(getSiteCacheTag(context.site.id));
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout context={context} withTracking={!GITBOOK_DISABLE_TRACKING}>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { fetchSiteContextByURL } from '@v2/lib/context';
import { createDataFetcher } from '@v2/lib/data';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from '@v2/lib/env';
import { createNoopImageResizer } from '@v2/lib/images';
import { createLinker } from '@v2/lib/links';
import { headers } from 'next/headers';
export type RouteParamMode = 'url-host' | 'url';
export type RouteLayoutParams = {
mode: string;
/** URL encoded site URL */
siteURL: string;
};
export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export function getStaticSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const dataFetcher = createDataFetcher();
const linker = createLinkerFromParams(params);
const imageResizer = createNoopImageResizer();
return fetchSiteContextByURL(
{
dataFetcher,
linker,
imageResizer,
},
{
url: url.toString(),
visitorAuthToken: null,
redirectOnError: false,
},
);
}
/**
* Get the site context when rendering dynamically.
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const headersSet = await headers();
const dataFetcher = createDataFetcher({
apiToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
apiEndpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
});
const linker = createLinkerFromParams(params);
const imageResizer = createNoopImageResizer();
return fetchSiteContextByURL(
{
dataFetcher,
linker,
imageResizer,
},
{
url: url.toString(),
visitorAuthToken: headersSet.get('x-gitbook-visitor-token'),
// TODO: set it only when the token comes from the cookies.
redirectOnError: true,
},
);
}
/**
* Get the decoded page path from the params.
*/
export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
}
function createLinkerFromParams(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const mode = getModeFromParams(params.mode);
if (mode === 'url-host') {
return createLinker({
host: url.host,
pathname: '/',
});
}
const gitbookURL = new URL(GITBOOK_URL);
return createLinker({
protocol: gitbookURL.protocol,
host: gitbookURL.host,
pathname: `/url/${url.host}`,
});
}
function getSiteURLFromParams(params: RouteLayoutParams) {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL('https://' + decoded);
return url;
}
function getModeFromParams(mode: string): RouteParamMode {
if (mode === 'url-host') {
return 'url-host';
}
return 'url';
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Get the cache tag for a site.
*/
export function getSiteCacheTag(siteId: string) {
return `site:${siteId}`;
}
/**
* Get the cache tag for a hostname.
*/
export function getHostnameCacheTag(hostname: string) {
return `url:${hostname}`;
}
/**
* Get the cache tag for an OpenAPI spec.
*/
export function getOpenAPISpecCacheTag(organizationId: string, slug: string) {
return `organization:${organizationId}:openapi:${slug}`;
}
/**
* Get the cache tag for a space.
*/
export function getSpaceCacheTag(spaceId: string) {
return `space:${spaceId}`;
}
/**
* Get the cache tag for a change request.
*/
export function getChangeRequestCacheTag(spaceId: string, changeRequestId: string) {
return `space:${spaceId}:change-request:${changeRequestId}`;
}
+289
View File
@@ -0,0 +1,289 @@
import {
ChangeRequest,
RevisionPage,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
SiteIntegrationScript,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
Space,
} from '@gitbook/api';
import { redirect } from 'next/navigation';
import { assert } from 'ts-essentials';
import { getSiteStructureSections } from '@/lib/sites';
import { createDataFetcher, GitBookDataFetcher } from '@v2/lib/data';
import { GitBookSpaceLinker, appendPrefixToLinker } from './links';
import { ImageResizer } from './images';
/**
* Generic context when rendering content.
*/
export type GitBookBaseContext = {
/**
* Data fetcher to fetch data from GitBook.
*/
dataFetcher: GitBookDataFetcher;
/**
* Linker to generate links in the current space.
*/
linker: GitBookSpaceLinker;
/**
* Image resizer to resize images.
*/
imageResizer: ImageResizer;
};
/**
* Any context when rendering content.
*/
export type GitBookAnyContext = GitBookSpaceContext | GitBookSiteContext | GitBookPageContext;
/**
* Context when rendering a space content.
*/
export type GitBookSpaceContext = GitBookBaseContext & {
organizationId: string;
space: Space;
changeRequest: ChangeRequest | null;
/** ID of the current revision. */
revisionId: string;
/** Pages of the space. */
pages: RevisionPage[];
/** Share key of the space. */
shareKey: string | undefined;
};
export type SiteSections = {
list: (SiteSectionGroup | SiteSection)[];
current: SiteSection;
};
/**
* Context when rendering a site.
*/
export type GitBookSiteContext = GitBookSpaceContext & {
site: Site;
/** Current site space. */
siteSpace: SiteSpace;
/** All site spaces in the current section / or entire site */
siteSpaces: SiteSpace[];
/** Sections of the site. */
sections: null | SiteSections;
/** Customizations of the site. */
customization: SiteCustomizationSettings;
/** Structure of the site. */
structure: SiteStructure;
/** Scripts to load for the site. */
scripts: SiteIntegrationScript[];
/** Visitor token used to fetch the site */
visitorAuthToken: string | null;
};
/**
* Context when rendering a page.
*/
export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
page: RevisionPageDocument;
};
/**
* Fetch the context of a site for a given URL and a base context.
*/
export async function fetchSiteContextByURL(
baseContext: GitBookBaseContext,
input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
},
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const data = await dataFetcher.getPublishedContentByUrl({
url: input.url,
visitorAuthToken: input.visitorAuthToken,
redirectOnError: input.redirectOnError,
});
if ('redirect' in data) {
redirect(data.redirect);
}
const context = await fetchSiteContextByIds(
{
...baseContext,
dataFetcher: createDataFetcher({
apiEndpoint: dataFetcher.apiEndpoint,
apiToken: data.apiToken,
}),
},
{
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
visitorAuthToken: input.visitorAuthToken,
},
);
const siteContext = {
...context,
linker: appendPrefixToLinker(context.linker, data.basePath),
};
return siteContext;
}
/**
* Fetch a site context by IDs.
*/
export async function fetchSiteContextByIds(
baseContext: GitBookBaseContext,
ids: {
organization: string;
site: string;
siteSection: string | undefined;
siteSpace: string | undefined;
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
visitorAuthToken: string | null;
},
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
await Promise.all([
dataFetcher.getPublishedContentSite({
organizationId: ids.organization,
siteId: ids.site,
siteShareKey: ids.shareKey,
}),
fetchSpaceContextByIds(baseContext, ids),
]);
// override the title with the customization title
// TODO: remove this hack once we have a proper way to handle site customizations
const site = {
...orgSite,
...(customizations.site?.title ? { title: customizations.site.title } : {}),
};
const sections = ids.siteSection ? parseSiteSectionsList(siteStructure, ids.siteSection) : null;
const siteSpace = (
siteStructure.type === 'siteSpaces' && siteStructure.structure
? siteStructure.structure
: sections?.current.siteSpaces
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
const siteSpaces =
siteStructure.type === 'siteSpaces'
? siteStructure.structure
: (sections?.current.siteSpaces ?? []);
const customization = (() => {
if (ids.siteSpace) {
const siteSpaceSettings = customizations.siteSpaces[ids.siteSpace];
if (siteSpaceSettings) {
return siteSpaceSettings;
}
// We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error.
console.warn('Customization not found for site space', ids.siteSpace);
}
return customizations.site;
})();
return {
...spaceContext,
organizationId: ids.organization,
site,
siteSpaces,
siteSpace,
customization,
structure: siteStructure,
sections,
scripts,
visitorAuthToken: ids.visitorAuthToken,
};
}
/**
* Fetch a space context by IDs.
*/
export async function fetchSpaceContextByIds(
baseContext: GitBookBaseContext,
ids: {
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
},
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
const [space, changeRequest] = await Promise.all([
dataFetcher.getSpace({
spaceId: ids.space,
shareKey: ids.shareKey,
}),
ids.changeRequest
? dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
})
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
const pages = await dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
});
return {
...baseContext,
organizationId: space.organization,
space,
pages,
changeRequest,
revisionId,
shareKey: ids.shareKey,
};
}
function parseSiteSectionsList(structure: SiteStructure, siteSectionId: string) {
const sections = getSiteStructureSections(structure);
const section = sections.find((section) => section.id === siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
return { list: sections, current: section } satisfies SiteSections;
}
+465
View File
@@ -0,0 +1,465 @@
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { ComputedContentSource, GitBookAPI } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { GitBookDataFetcher } from './types';
import {
getChangeRequestCacheTag,
getHostnameCacheTag,
getOpenAPISpecCacheTag,
getSiteCacheTag,
getSpaceCacheTag,
} from '../cache';
interface DataFetcherInput {
/**
* API host to use.
*/
apiEndpoint: string;
/**
* API token.
*/
apiToken: string | null;
}
const commonInput: DataFetcherInput = {
apiEndpoint: GITBOOK_API_URL,
apiToken: GITBOOK_API_TOKEN,
};
/**
* Create a data fetcher using an API token.
* The data are being cached by Next.js built-in cache.
*/
export function createDataFetcher(input: DataFetcherInput = commonInput): GitBookDataFetcher {
return {
apiEndpoint: input.apiEndpoint,
//
// API that are tied to the token
//
getPublishedContentSite(params) {
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
},
getSiteRedirectBySource(params) {
return getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
});
},
getRevision(params) {
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionPages(params) {
return getRevisionPages(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionFile(params) {
return getRevisionFile(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
fileId: params.fileId,
});
},
getRevisionPageByPath(params) {
return getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
});
},
getReusableContent(params) {
return getReusableContent(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
reusableContentId: params.reusableContentId,
});
},
getLatestOpenAPISpecVersionContent(params) {
return getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
});
},
getSpace(params) {
return getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
});
},
getChangeRequest(params) {
return getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
});
},
getDocument(params) {
return getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
});
},
getComputedDocument(params) {
return getComputedDocument(input, {
spaceId: params.spaceId,
source: params.source,
});
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return getUserById(commonInput, userId);
},
getPublishedContentByUrl(params) {
return getPublishedContentByUrl(commonInput, {
url: params.url,
visitorAuthToken: params.visitorAuthToken,
redirectOnError: params.redirectOnError,
});
},
};
}
async function getUserById(input: DataFetcherInput, userId: string) {
'use cache';
try {
const res = await getAPI(input).users.getUserById(userId);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
shareKey: string | undefined;
},
) {
'use cache';
cacheTag(getSpaceCacheTag(params.spaceId));
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
}
async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
changeRequestId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId,
);
cacheTag(getChangeRequestCacheTag(params.spaceId, res.data.id));
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
},
) {
'use cache';
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
}
async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
},
) {
'use cache';
const res = await getAPI(input).spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
);
return res.data.pages;
}
async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
fileId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
path: string;
},
) {
'use cache';
const encodedPath = encodeURIComponent(params.path);
try {
const res = await getAPI(input).spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
documentId: string;
},
) {
'use cache';
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
return res.data;
}
async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
source: ComputedContentSource;
},
) {
'use cache';
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
source: params.source,
});
return res.data;
}
async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
},
) {
'use cache';
cacheTag(getOpenAPISpecCacheTag(params.organizationId, params.slug));
try {
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getPublishedContentByUrl(
input: DataFetcherInput,
params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
},
) {
'use cache';
const { url, visitorAuthToken, redirectOnError } = params;
const hostname = new URL(url).hostname;
cacheTag(getHostnameCacheTag(hostname));
const res = await getAPI(input).urls.getPublishedContentByUrl({
url,
visitorAuthToken: visitorAuthToken ?? undefined,
redirectOnError,
});
if ('site' in res.data) {
cacheTag(getSiteCacheTag(res.data.site));
}
return res.data;
}
async function getPublishedContentSite(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
},
) {
'use cache';
cacheTag(getSiteCacheTag(params.siteId));
const res = await getAPI(input).orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
},
);
return res.data;
}
async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
},
) {
'use cache';
try {
const res = await getAPI(input).orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
},
);
return res.data;
} catch (error) {
// 422 is returned when the source is invalid
// we don't want to throw but just return null
if (checkHasErrorCode(error, 422)) {
return null;
}
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
function getAPI(input: DataFetcherInput) {
const { apiEndpoint, apiToken } = input;
const api = new GitBookAPI({
authToken: apiToken ?? undefined,
endpoint: apiEndpoint,
userAgent: GITBOOK_USER_AGENT,
});
return api;
}
function checkHasErrorCode(error: unknown, code: number) {
return error instanceof Error && 'code' in error && error.code === code;
}
@@ -0,0 +1,4 @@
export * from './api';
export * from './types';
export * from './utils';
export * from './urls';
+124
View File
@@ -0,0 +1,124 @@
import type * as api from '@gitbook/api';
/**
* Generic fetcher for GitBook data.
* It is used between v1 and v2.
*/
export interface GitBookDataFetcher {
/**
* Endpoint of the API.
*/
apiEndpoint: string;
/**
* Get a user by its ID.
*/
getUserById(userId: string): Promise<api.User | null>;
/**
* Get a published content by its URL.
*/
getPublishedContentByUrl(params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<api.PublishedSiteContentLookup>;
/**
* Get a published content site by its organization ID and site ID.
*/
getPublishedContentSite(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}): Promise<api.PublishedContentSite>;
/**
* Get a space by its ID.
*/
getSpace(params: { spaceId: string; shareKey: string | undefined }): Promise<api.Space>;
/**
* Get a change request by its space ID and change request ID.
*/
getChangeRequest(params: {
spaceId: string;
changeRequestId: string;
}): Promise<api.ChangeRequest | null>;
/**
* Get the revision by its space ID and revision ID.
*/
getRevision(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.Revision>;
/**
* Get the revision pages by its space ID and revision ID.
*/
getRevisionPages(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.RevisionPage[]>;
/**
* Get a revision file by its space ID, revision ID and file ID.
*/
getRevisionFile(params: {
spaceId: string;
revisionId: string;
fileId: string;
}): Promise<api.RevisionFile | null>;
/**
* Get a revision page by its path.
*/
getRevisionPageByPath(params: {
spaceId: string;
revisionId: string;
path: string;
}): Promise<api.RevisionPageDocument | api.RevisionPageGroup | null>;
/**
* Get a document by its space ID and document ID.
*/
getDocument(params: { spaceId: string; documentId: string }): Promise<api.JSONDocument>;
/**
* Get a computed document by its space ID and computed source.
*/
getComputedDocument(params: {
spaceId: string;
source: api.ComputedContentSource;
}): Promise<api.JSONDocument>;
/**
* Get a reusable content by its space ID, revision ID and reusable content ID.
*/
getReusableContent(params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}): Promise<api.RevisionReusableContent | null>;
/**
* Get the latest OpenAPI spec version content by its organization ID and slug.
*/
getLatestOpenAPISpecVersionContent(params: {
organizationId: string;
slug: string;
}): Promise<api.OpenAPISpecContent | null>;
/**
* Get a site redirect by its source path.
*/
getSiteRedirectBySource(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
}): Promise<{ redirect: api.SiteRedirect | null; target: string } | null>;
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import { getURLLookupAlternatives, normalizeURL } from './middleware'; import { getURLLookupAlternatives, normalizeURL } from './urls';
describe('getURLLookupAlternatives', () => { describe('getURLLookupAlternatives', () => {
it('should return all URLs up to the root', () => { it('should return all URLs up to the root', () => {
+252
View File
@@ -0,0 +1,252 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { GitBookAPI, type GitBookAPIError, type PublishedSiteContentLookup } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
/**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<
| { data: PublishedSiteContentLookup; error?: undefined }
| { data?: undefined; error: Error | GitBookAPIError }
> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
console.log(
`lookup content for url "${url.toString()}", with ${lookup.urls.length} alternatives`,
);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = new GitBookAPI({
authToken: GITBOOK_API_TOKEN ?? undefined,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
const callResult = await tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
cache: true,
},
{
signal,
headers: {
'x-gitbook-force-cache': 'true',
},
},
),
);
if (callResult.error) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),
// as other parts could result in errors due to the URL being incomplete (share links, etc).
return { error: callResult.error };
}
return null;
}
const {
data: { data },
} = callResult;
if ('redirect' in data) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath,
),
);
data.redirect = redirect.toString();
}
}
}
return { data };
}
return null;
}
/**
* We use the following criteria to determine if the lookup result is the right one:
* - the primary alternative was resolved (because that's the longest or most inclusive path)
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
*
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
*/
if (alternative.primary || ('site' in data && data.complete)) {
const siteResult: PublishedSiteContentLookup = {
...data,
changeRequest: data.changeRequest ?? lookup.changeRequest,
revision: data.revision ?? lookup.revision,
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
};
return { data: siteResult };
}
return null;
});
if (!result) {
return {
error: new Error('No content found'),
};
}
return result;
}
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
* The approach is optimized to aim at reusing cached lookup results as much as possible.
*
* The cases can be:
*
* Custom hostname
* - Public content has a custom hostname: docs.company.com/<path>
* - Public content has a custom hostname with a share-link: docs.company.com/<link>/<path>
* - Public content has a custom hostname with a variant: docs.company.com/v/<variant>/<path>
* - Public content has a custom hostname with a variant and a share-link: docs.company.com/<link>/v/<variant>/<path>
* - Revisions in a custom hostname: docs.company.com/~/revisions/<id>/<path>
* - Changes in a custom hostname: docs.company.com/~/changes/<id>/<path>
*
* Custom hostname in the organization or GitBook domain (company.gitbook.io)
* - Public content has a custom hostname in the organization: docs.company.com/<space>/<path>
* - Public content has a custom hostname in the organization with a share-link: docs.company.com/<space>/<link>/<path>
* - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path>
* - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path>
*/
export function getURLLookupAlternatives(input: URL) {
const url = normalizeURL(input);
let basePath: string | undefined = undefined;
let changeRequest: string | undefined = undefined;
let revision: string | undefined = undefined;
const alternatives: Array<{ url: string; extraPath: string; primary: boolean }> = [];
const pushAlternative = (adding: URL, extraPath: string) => {
const existing = alternatives.find((alt) => alt.url === adding.toString());
if (existing) {
if (existing.extraPath !== extraPath) {
throw new Error(
`Invalid extraPath ${extraPath} for url ${adding.toString()}, already set to ${
existing.extraPath
}`,
);
}
return;
}
alternatives.push({
url: adding.toString(),
extraPath,
primary: adding.toString() === url.toString(),
});
};
const pathSegments = url.pathname.slice(1).split('/');
const tildeIndex = pathSegments.indexOf('~');
// URL looks like a specific content url (with ~/revisions/ or ~/changes/ in the path)
// We only start matching after the ~/revisions/ or ~/changes/ segment and we ignore everything before it
if (
tildeIndex >= 0 &&
(pathSegments[tildeIndex + 1] === 'revisions' || pathSegments[tildeIndex + 1] === 'changes')
) {
const tildeIndex = pathSegments.indexOf('~');
const revisionOrChangeIdIndex = tildeIndex + 2;
basePath = pathSegments.slice(tildeIndex, revisionOrChangeIdIndex + 1).join('/');
if (pathSegments[tildeIndex + 1] === 'revisions') {
revision = pathSegments[revisionOrChangeIdIndex];
} else {
changeRequest = pathSegments[revisionOrChangeIdIndex];
}
// Match up to the tilde
const contentURL = new URL(url);
contentURL.pathname = pathSegments.slice(0, tildeIndex).join('/');
pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/'));
}
// URL looks like a collection url (with /v/ in the path)
// We only start matching after the /v/ segment and we ignore everything before it
// to avoid potentially matching as a page not found under the default space in the collection
else if (pathSegments.includes('v')) {
const collectionURL = new URL(url);
const vIndex = pathSegments.indexOf('v');
collectionURL.pathname = pathSegments.slice(0, vIndex + 2).join('/');
pushAlternative(collectionURL, pathSegments.slice(vIndex + 2).join('/'));
} else {
// Match only with the host, if it can be a custom hostname
// It should cover most cases of custom domains, and with caching, it should be fast.
if (!url.hostname.includes('.gitbook.io') || pathSegments.length === 0) {
const noPathURL = new URL(url);
noPathURL.pathname = '/';
pushAlternative(noPathURL, url.pathname.slice(1));
}
// Otherwise match with the first four segments of the path
for (let i = 1; i <= 4; i++) {
if (pathSegments.length >= i) {
const shortURL = new URL(url);
shortURL.pathname = pathSegments.slice(0, i).join('/');
pushAlternative(shortURL, pathSegments.slice(i).join('/'));
}
}
}
// Mark the longuest entry to lookup as primary
alternatives.sort((a, b) => b.extraPath.length - a.extraPath.length);
if (alternatives.length > 0) {
alternatives[alternatives.length - 1].primary = true;
}
return { urls: alternatives, basePath, changeRequest, revision };
}
/**
* Normalize a URL to remove duplicate slashes and trailing slashes
* and transform the pathname to lowercase.
*/
export function normalizeURL(url: URL) {
const result = new URL(url);
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
return result;
}
/**
* Strip the search params from a URL
*/
export function stripURLSearch(url: URL): URL {
const stripped = new URL(url.toString());
stripped.search = '';
return stripped;
}
+19
View File
@@ -0,0 +1,19 @@
import { RevisionPageDocument } from '@gitbook/api';
import { GitBookDataFetcher } from './types';
/**
* Get the document for a page.
*/
export async function getPageDocument(
dataFetcher: GitBookDataFetcher,
spaceId: string,
page: RevisionPageDocument,
) {
if (page.documentId) {
return dataFetcher.getDocument({ spaceId, documentId: page.documentId });
} else if (page.computed) {
return dataFetcher.getComputedDocument({ spaceId, source: page.computed });
}
return null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Main host on which GitBook is running.
*/
export const GITBOOK_URL =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: ((process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ??
process.env.GITBOOK_URL ??
'');
/**
* GitBook app URL.
*/
export const GITBOOK_APP_URL = process.env.NEXT_PUBLIC_GITBOOK_APP_URL ?? `https://app.gitbook.com`;
/**
* 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';
/**
* Whether to disable tracking of events into site insights.
* This is used to disable tracking in development mode.
*/
export const GITBOOK_DISABLE_TRACKING = Boolean(
!!process.env.GITBOOK_DISABLE_TRACKING || process.env.NODE_ENV !== 'production',
);
@@ -1,13 +1,10 @@
import 'server-only'; import 'server-only';
import fnv1a from '@sindresorhus/fnv1a'; import type { ImageResizer } from './types';
import { generateImageSignature, SignatureVersion } from './signatures';
import { GitBookSpaceLinker } from '../links';
import { noCacheFetchOptions } from '@/lib/cache/http'; interface CloudflareImageJsonFormat {
import { host, rootUrl } from './links';
import { getImageAPIUrl } from './urls';
export interface CloudflareImageJsonFormat {
width: number; width: number;
height: number; height: number;
original: { original: {
@@ -31,7 +28,77 @@ export interface CloudflareImageOptions {
quality?: number; quality?: number;
} }
export const imagesResizingSignVersion = '2'; /**
* Create an image resizer for a rendering context.
*/
export function createImageResizer({
host,
linker,
}: {
/** The linker to use to create URLs. */
linker: GitBookSpaceLinker;
/** The host name of the current site. */
host: string;
}): ImageResizer {
return {
resize: (urlInput) => {
if (!checkIsSizableImageURL(urlInput)) {
return null;
}
let cachedSignature: {
signature: string;
version: SignatureVersion;
} | null = null;
return async (options) => {
cachedSignature ??= await generateImageSignature({
host,
url: urlInput,
});
const url = new URL(linker.toAbsoluteURL('/~gitbook/image'));
url.searchParams.set('url', getImageAPIUrl(urlInput));
if (options.width) {
url.searchParams.set('width', options.width.toString());
}
if (options.height) {
url.searchParams.set('height', options.height.toString());
}
if (options.dpr) {
url.searchParams.set('dpr', options.dpr.toString());
}
if (options.quality) {
url.searchParams.set('quality', options.quality.toString());
}
url.searchParams.set('sign', cachedSignature.signature);
url.searchParams.set('sv', cachedSignature.version);
return url.toString();
};
},
getImageSize: async (input, options) => {
if (!checkIsSizableImageURL(input)) {
return null;
}
return getImageSize(input, options);
},
};
}
/**
* Create an image resizer that doesn't do any resizing.
*/
export function createNoopImageResizer(): ImageResizer {
return {
resize: () => null,
getImageSize: async (input) => null,
};
}
/** /**
* Return true if images resizing is enabled. * Return true if images resizing is enabled.
@@ -77,68 +144,6 @@ export function checkIsSizableImageURL(input: string): boolean {
return true; return true;
} }
interface ResizeImageOptions {
width?: number;
height?: number;
dpr?: number;
quality?: number;
}
/**
* Create a function to get resized image URLs for a given image URL.
*/
export function getResizedImageURLFactory(
input: string,
): ((options: ResizeImageOptions) => string) | null {
if (!checkIsSizableImageURL(input)) {
return null;
}
const signature = generateSignature(input);
return (options) => {
const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', getImageAPIUrl(input));
if (options.width) {
url.searchParams.set('width', options.width.toString());
}
if (options.height) {
url.searchParams.set('height', options.height.toString());
}
if (options.dpr) {
url.searchParams.set('dpr', options.dpr.toString());
}
if (options.quality) {
url.searchParams.set('quality', options.quality.toString());
}
url.searchParams.set('sign', signature);
url.searchParams.set('sv', imagesResizingSignVersion);
return url.toString();
};
}
/**
* Create a new URL for an image with resized parameters.
* The URL is signed and verified by the server.
*/
export function getResizedImageURL(input: string, options: ResizeImageOptions): string {
const factory = getResizedImageURLFactory(input);
return factory?.(options) ?? input;
}
/**
* Verify a signature of an image URL
*/
export async function verifyImageSignature(
input: string,
{ signature }: { signature: string },
): Promise<boolean> {
return generateSignature(input) === signature;
}
/** /**
* Get the size of an image. * Get the size of an image.
*/ */
@@ -212,7 +217,6 @@ export async function resizeImage(
: `image/${resizeOptions.format || 'jpeg'}`, : `image/${resizeOptions.format || 'jpeg'}`,
}, },
signal, signal,
...noCacheFetchOptions,
}, },
); );
@@ -234,21 +238,20 @@ function stringifyOptions(options: CloudflareImageOptions): string {
}, ''); }, '');
} }
// Reused buffer for FNV-1a hashing /**
const fnv1aUtf8Buffer = new Uint8Array(512); * Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
* the token in the image API.
*/
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
/** /**
* Generate a signature for an image. * Prepare a URL for the GitBook Open Image API.
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
*/ */
function generateSignature(input: string) { export function getImageAPIUrl(url: string): string {
const hostName = host(); return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
const all = [ }
input,
hostName, // The hostname is used to avoid serving images from other sites on the same domain export function parseImageAPIURL(url: string): string {
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY, return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
} }
@@ -0,0 +1,4 @@
export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
@@ -0,0 +1,107 @@
import 'server-only';
import fnv1a from '@sindresorhus/fnv1a';
import type { MaybePromise } from 'p-map';
/**
* GitBook has supported different version of image signing in the past. To maintain backwards
* compatibility, we retain the ability to verify older signatures.
*/
export type SignatureVersion = '0' | '1' | '2';
/**
* The current version of the signature.
*/
export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
/**
* Verify a signature of an image URL
*/
export async function verifyImageSignature(
input: SignFnInput,
{ signature, version }: { signature: string; version: SignatureVersion },
): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
return generated === signature;
}
/**
* Generate an image signature. Also returns the version of the image signing algorithm that was used.
*
* This function is sync. If you need to implement an async version of image signing, you'll need to change
* ths signature of this fn and where it's used.
*/
export async function generateImageSignature(input: SignFnInput): Promise<{
signature: string;
version: SignatureVersion;
}> {
const result = await generateSignatureV2(input);
return { signature: result, version: CURRENT_SIGNATURE_VERSION };
}
// Reused buffer for FNV-1a hashing in the v2 algorithm
const fnv1aUtf8Buffer = new Uint8Array(512);
/**
* Generate a signature for an image.
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
*/
const generateSignatureV2: SignFn = async (input) => {
const all = [
input.url,
input.host, // The hostname is used to avoid serving images from other sites on the same domain
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
};
// Reused buffer for FNV-1a hashing in the v1 algorithm
const fnv1aUtf8BufferV1 = new Uint8Array(512);
/**
* New and faster algorithm to generate a signature for an image.
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
* to know that it was the algorithm that was used.
*/
const generateSignatureV1: SignFn = async (input) => {
const all = [input.url, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16);
};
/**
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
* We still need it to validate older signatures that were generated without versioning
* but still exist in previously generated and cached content.
*/
const generateSignatureV0: SignFn = async (input) => {
const all = [input.url, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
// Convert ArrayBuffer to hex string
const hashArray = Array.from(new Uint8Array(hash));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
};
/**
* A mapping of signature versions to signature functions.
*/
const IMAGE_SIGNATURE_FUNCTIONS: Record<SignatureVersion, SignFn> = {
'0': generateSignatureV0,
'1': generateSignatureV1,
'2': generateSignatureV2,
};
export function isSignatureVersion(input: string): input is SignatureVersion {
return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input);
}
@@ -0,0 +1,29 @@
export type GetImageSizeOptions = {
dpr?: number;
};
export type ResizeImageOptions = GetImageSizeOptions & {
width?: number;
height?: number;
dpr?: number;
quality?: number;
};
interface ImageSize {
width: number;
height: number;
}
export type ImageResizer = {
/**
* Resize an image.
* @param input - The image URL to resize.
* @param options - The options to resize the image.
*/
resize(input: string): null | ((options: ResizeImageOptions) => Promise<string>);
/**
* Get the size of an image.
*/
getImageSize(input: string, options: GetImageSizeOptions): Promise<ImageSize | null>;
};
@@ -0,0 +1,17 @@
import { ImageResizer, ResizeImageOptions } from './types';
/**
* Quick utility to get a resized image URL.
*/
export async function getResizedImageURL(
resizer: ImageResizer,
url: string,
options: ResizeImageOptions,
) {
const getURL = resizer.resize(url);
if (!getURL) {
return url;
}
return await getURL(options);
}
+91
View File
@@ -0,0 +1,91 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import { getPagePath } from '@/lib/pages';
/**
* Generic interface to generate links based on a given context.
*/
export interface GitBookSpaceLinker {
/**
* Generate an absolute path for a relative path in the current space content.
*/
toPathInSpace(relativePath: string): string;
/**
* Generate an absolute path for a page in the current space.
*/
toPathForPage(input: {
pages: RevisionPage[];
page: RevisionPageDocument | RevisionPageGroup;
anchor?: string;
}): string;
/**
* Generate an absolute URL for a given path.
*/
toAbsoluteURL(absolutePath: string): string;
}
/**
* Create a linker to resolve links in a context being served on a specific URL.
*/
export function createLinker(
/** Where the top of the space is served on */
servedOn: {
protocol?: string;
host: string;
pathname: string;
},
): GitBookSpaceLinker {
if (servedOn.host.includes('/')) {
throw new Error('Host cannot include a slash');
}
const linker: GitBookSpaceLinker = {
toPathInSpace(relativePath: string): string {
return joinPaths(servedOn.pathname, relativePath);
},
toAbsoluteURL(absolutePath: string): string {
return `${servedOn.protocol ?? 'https:'}//${joinPaths(servedOn.host, absolutePath)}`;
},
toPathForPage({ pages, page, anchor }) {
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? '#' + anchor : '');
},
};
return linker;
}
/**
* Append a prefix to a linker.
*/
export function appendPrefixToLinker(
linker: GitBookSpaceLinker,
prefix: string,
): GitBookSpaceLinker {
const linkerWithPrefix: GitBookSpaceLinker = {
toPathInSpace(relativePath: string): string {
return linker.toPathInSpace(joinPaths(prefix, relativePath));
},
toAbsoluteURL(absolutePath: string): string {
return linker.toAbsoluteURL(joinPaths(prefix, absolutePath));
},
toPathForPage({ pages, page, anchor }) {
return (
linkerWithPrefix.toPathInSpace(getPagePath(pages, page)) +
(anchor ? '#' + anchor : '')
);
},
};
return linkerWithPrefix;
}
function joinPaths(prefix: string, path: string): string {
const prefixPath = prefix.endsWith('/') ? prefix : prefix + '/';
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
return prefixPath + suffixPath;
}
+36
View File
@@ -0,0 +1,36 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers';
export enum MiddlewareHeaders {
URLMode = 'x-gitbook-url-mode',
Theme = 'x-gitbook-theme',
Customization = 'x-gitbook-customization',
}
/**
* Get the URL mode from the middleware headers.
*/
export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
const headersList = await headers();
const mode = headersList.get(MiddlewareHeaders.URLMode);
if (!mode) {
throw new Error('URL mode is not set by the middleware');
}
return mode as 'url' | 'url-host';
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
export async function getThemeFromMiddleware() {
const headersList = await headers();
const queryStringTheme = headersList.get(MiddlewareHeaders.Theme);
if (!queryStringTheme) {
return null;
}
return queryStringTheme === 'light'
? CustomizationThemeMode.Light
: CustomizationThemeMode.Dark;
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { GitBookAPIError } from '@gitbook/api';
import { removeTrailingSlash } from '@/lib/paths';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import { getPublishedContentByURL } from '@v2/lib/data';
export const config = {
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
};
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
export async function middleware(request: NextRequest) {
const extracted = extractURL(request);
if (extracted) {
return serveSiteByURL(request, extracted);
}
return NextResponse.next();
}
/**
* Serve site by URL.
*/
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
const dynamicHeaders = getDynamicHeaders(request);
const { url, mode } = urlWithMode;
const result = await getPublishedContentByURL({
url: url.toString(),
visitorAuthToken: null,
redirectOnError: false,
});
if (result.error) {
if (result.error instanceof GitBookAPIError) {
return NextResponse.json(
{
error: result.error.message,
},
{ status: result.error.code },
);
} else {
throw result.error;
}
}
const { data } = result;
if ('redirect' in data) {
return NextResponse.redirect(data.redirect);
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
if (dynamicHeaders) {
for (const [key, value] of Object.entries(dynamicHeaders)) {
requestHeaders.set(key, value);
}
}
const route = [
'sites',
dynamicHeaders ? 'dynamic' : 'static',
mode,
encodeURIComponent(url.host + data.basePath),
encodeURIComponent(removeTrailingSlash(data.pathname) || '/'),
].join('/');
return NextResponse.rewrite(new URL('/' + route, request.url), {
headers: requestHeaders,
});
}
/**
* The URL of the GitBook content can be passed in 2 different ways:
* - Hostname is in the `X-GitBook-Host` header and the pathname is the path in the request URL.
* - The request URL is matching `/url/:url`
*/
function extractURL(request: NextRequest): URLWithMode | null {
const xGitbookHost = request.headers.get('x-gitbook-host');
if (xGitbookHost) {
return {
url: new URL(`https://${xGitbookHost}${request.nextUrl.pathname}`),
mode: 'url-host',
};
}
const prefix = '/url/';
if (request.nextUrl.pathname.startsWith(prefix)) {
return {
url: new URL(`https://${request.nextUrl.pathname.slice(prefix.length)}`),
mode: 'url',
};
}
return null;
}
/**
* Evaluate if a request is dynamic or static.
*/
function getDynamicHeaders(request: NextRequest): null | Record<string, string> {
// TODO:
// - check token in query string
// - check token in cookies
// - check special headers or query string
return null;
}
+10
View File
@@ -0,0 +1,10 @@
import config from '../gitbook/tailwind.config';
export default {
...config,
content: [
'../gitbook/src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/components/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
};
+44
View File
@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@v2/*": ["./src/*"],
"@/*": ["../gitbook/src/*"]
},
"types": [
"bun-types" // add Bun global
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
"../gitbook/types/**/*.d.ts",
"../gitbook/cf-env.d.ts"
],
"exclude": [
"node_modules",
"packages/openapi-parser",
"packages/react-openapi",
"packages/react-math",
"packages/gitbook"
]
}
+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" }
+208
View File
@@ -1,5 +1,213 @@
# gitbook # gitbook
## 0.6.3
### Patch Changes
- a820739: Remove unused search api method from gitbook/api/lib
- a054554: Implement a trusted mode to speed up OpenAPI spec validation
- 66d0fc0: Update design for hint block: use semantic colors (info, warning, danger, success) and add alternative styling for hints with headings
- 9f0de74: Add support for new OpenAPI ref
- da55fac: Render GitBook blocks in OpenAPI operation description
- Updated dependencies [c808bb1]
- Updated dependencies [dc2dbc5]
- Updated dependencies [f1d1d2f]
- Updated dependencies [e24206e]
- Updated dependencies [a054554]
- Updated dependencies [05e1d8c]
- Updated dependencies [b4a12d6]
- Updated dependencies [9f0de74]
- Updated dependencies [da55fac]
- @gitbook/openapi-parser@2.0.0
- @gitbook/react-openapi@1.0.3
## 0.6.2
### Patch Changes
- 359bb97: Fix opening external links when the GitBook page is embedded in an iframe
- 6157583: Improve Markdown parsing
- 82cd9f2: Add support for anchor links in OpenAPI blocks
- Updated dependencies [445baaa]
- Updated dependencies [bb5c6a4]
- Updated dependencies [a3f1fea]
- Updated dependencies [6157583]
- Updated dependencies [7419ee7]
- Updated dependencies [82cd9f2]
- @gitbook/colors@0.2.0
- @gitbook/react-openapi@1.0.2
- @gitbook/openapi-parser@1.0.1
## 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 ## 0.5.0
### Minor Changes ### 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 { import {
CustomizationBackground, CustomizationBackground,
CustomizationCorners, CustomizationCorners,
CustomizationFont,
CustomizationHeaderPreset, CustomizationHeaderPreset,
CustomizationIconsStyle, CustomizationIconsStyle,
CustomizationLocale, CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api'; } from '@gitbook/api';
import { test, expect, Page } from '@playwright/test'; import { expect } from '@playwright/test';
import deepMerge from 'deepmerge';
import jwt from 'jsonwebtoken'; 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 { import {
name: string; allDeprecatedThemePresets,
url: string; // URL to visit for testing allLocales,
run?: (page: Page) => Promise<unknown>; // The test to run allSidebarBackgroundStyles,
fullPage?: boolean; // Whether the test should be fullscreened during testing allThemeModes,
screenshot?: false; // Should a screenshot be stored allThemes,
only?: boolean; // Only run this test allTintColors,
} getCustomizationURL,
headerLinks,
interface TestsCase { runTestCases,
name: string; TestsCase,
baseUrl: string; waitForCookiesDialog,
tests: Array<Test>; } from './util';
}
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();
}
const testCases: TestsCase[] = [ const testCases: TestsCase[] = [
{ {
@@ -81,19 +50,28 @@ const testCases: TestsCase[] = [
{ {
name: 'Search', name: 'Search',
url: '?q=', 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', name: 'Search Results',
url: '?q=gitbook', url: '?q=gitbook',
run: async (page) => { run: async (page) => {
await page.waitForSelector('[data-test="search-results"]'); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
{ {
name: 'AI Search', name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true', url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => { run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]'); await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
}, },
screenshot: false, screenshot: false,
}, },
@@ -254,19 +232,28 @@ const testCases: TestsCase[] = [
{ {
name: 'Search', name: 'Search',
url: '?q=', 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', name: 'Search Results',
url: '?q=gitbook', url: '?q=gitbook',
run: async (page) => { run: async (page) => {
await page.waitForSelector('[data-test="search-results"]'); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
{ {
name: 'AI Search', name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true', url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => { run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]'); await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
}, },
screenshot: false, screenshot: false,
}, },
@@ -295,6 +282,9 @@ const testCases: TestsCase[] = [
{ {
name: 'PDF', name: 'PDF',
url: '~gitbook/pdf?limit=10', url: '~gitbook/pdf?limit=10',
screenshot: {
waitForTOCScrolling: false,
},
}, },
], ],
}, },
@@ -317,11 +307,38 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images', url: 'blocks/block-images',
run: waitForCookiesDialog, run: waitForCookiesDialog,
fullPage: true, 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', name: 'Inline Images',
url: 'blocks/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', name: 'Tabs',
@@ -392,6 +409,18 @@ const testCases: TestsCase[] = [
{ {
name: 'Math', name: 'Math',
url: 'blocks/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', name: 'Files',
@@ -445,6 +474,9 @@ const testCases: TestsCase[] = [
name: 'With cover and no TOC', name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog, run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
}, },
{ {
name: 'With icon', name: 'With icon',
@@ -456,23 +488,23 @@ const testCases: TestsCase[] = [
{ {
name: 'Customization', name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', 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({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.None, preset: CustomizationHeaderPreset.None,
links: [], links: [],
}, },
themes: { themes: {
default: theme, default: themeMode,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `With duotone icons - Theme ${theme}`, name: `With duotone icons - Theme mode ${themeMode}`,
url: url:
'page-options/page-with-icon' + 'page-options/page-with-icon' +
getCustomizationURL({ getCustomizationURL({
@@ -480,119 +512,108 @@ const testCases: TestsCase[] = [
icons: CustomizationIconsStyle.Duotone, icons: CustomizationIconsStyle.Duotone,
}, },
themes: { themes: {
default: theme, default: themeMode,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `With header buttons - Theme ${theme}`, name: `With header buttons - Theme mode ${themeMode}`,
url: getCustomizationURL({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: [ links: headerLinks,
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
}, },
themes: { themes: {
default: theme, default: themeMode,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `Without tint - Default preset - Theme ${theme}`, name: `Without tint - Default preset - Theme mode ${themeMode}`,
url: getCustomizationURL({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: [ links: headerLinks,
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
}, },
themes: { themes: {
default: theme, default: themeMode,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
...allThemePresets.flatMap((preset) => ({ // New site themes
name: `With tint - Preset ${preset} - Theme ${theme}`, ...allThemes.flatMap((theme) => [
url: getCustomizationURL({ ...allTintColors.flatMap((tint) => [
styling: { ...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
tint: { color: { light: '#346DDB', dark: '#346DDB' } }, name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
}, url: getCustomizationURL({
header: { styling: {
preset, theme,
...(preset === CustomizationHeaderPreset.Custom ...(tint.value ? { tint: { color: tint.value } } : {}),
? { sidebar: {
backgroundColor: { light: '#C62C68', dark: '#EF96B8' }, background: sidebarStyle,
linkColor: { light: '#4DDE98', dark: '#0C693D' }, list: CustomizationSidebarListStyle.Default,
} },
: {}),
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
}, },
{ header: {
title: 'Primary button', links: headerLinks,
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
}, },
], themes: {
}, default: themeMode,
themes: { toggeable: false,
default: theme, },
toggeable: false, }),
}, run: waitForCookiesDialog,
}), })),
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({ url: getCustomizationURL({
styling: { styling: {
background: CustomizationBackground.Match, background: CustomizationBackground.Match,
}, },
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: [ links: headerLinks,
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
}, },
themes: { themes: {
default: theme, default: themeMode,
toggeable: false, toggeable: false,
}, },
}), }),
@@ -621,11 +642,14 @@ const testCases: TestsCase[] = [
run: async (page) => { run: async (page) => {
const sharedSpaceLink = page.locator('a.underline'); const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click(); await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared'); await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const url = page.url(); const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page expect(url.endsWith('/shared')).toBeTruthy(); // correct page
}, },
screenshot: false,
}, },
], ],
}, },
@@ -637,13 +661,15 @@ const testCases: TestsCase[] = [
name: 'Navigation to shared space', name: 'Navigation to shared space',
url: '', url: '',
run: async (page) => { run: async (page) => {
const sharedSpaceLink = page.locator('a.underline'); await page.locator('a.underline').click();
await sharedSpaceLink.click(); await expect(
expect(page.locator('h1')).toHaveText('shared'); page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const url = page.url(); const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page expect(url.endsWith('/shared')).toBeTruthy(); // correct page
}, },
screenshot: false,
}, },
], ],
}, },
@@ -657,6 +683,7 @@ const testCases: TestsCase[] = [
run: async (page) => { run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO'); await expect(page.locator('h1')).toHaveText('SSO');
}, },
screenshot: false,
}, },
], ],
}, },
@@ -676,6 +703,7 @@ const testCases: TestsCase[] = [
page.getByText('Authentication missing to access this content'), page.getByText('Authentication missing to access this content'),
).toBeVisible(); ).toBeVisible();
}, },
screenshot: false,
}, },
], ],
}, },
@@ -698,7 +726,12 @@ const testCases: TestsCase[] = [
); );
return `first?jwt_token=${token}`; return `first?jwt_token=${token}`;
})(), })(),
run: waitForCookiesDialog, run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
}, },
{ {
name: 'Second', name: 'Second',
@@ -715,7 +748,12 @@ const testCases: TestsCase[] = [
); );
return `second?jwt_token=${token}`; 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}`; 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', 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', name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', 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', name: 'Tables',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1108,97 +1348,4 @@ const testCases: TestsCase[] = [
}, },
]; ];
for (const testCase of testCases) { runTestCases(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()}`;
}
+338
View File
@@ -0,0 +1,338 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemedColor,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { BrowserContext, expect, Page, test } 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' },
infoColor: { light: '#787878', dark: '#787878' },
warningColor: { light: '#FE9A00', dark: '#FE9A00' },
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
successColor: { light: '#00C950', dark: '#00C950' },
corners: CustomizationCorners.Rounded,
font: CustomizationFont.Inter,
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
},
links: CustomizationLinksStyle.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: { env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7), BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development', SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX, GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL, GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
}, },
webpack(config, { dev, webpack }) { webpack(config, { dev, webpack }) {
@@ -67,19 +69,15 @@ module.exports = withSentryConfig(
}, },
}, },
{ {
silent: true, release: process.env.SENTRY_RELEASE,
org: process.env.SENTRY_ORG, org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT, 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) // Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true, widenClientFileUpload: true,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring', tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true, disableLogger: true,
automaticVercelMonitors: false,
}, },
); );
+24 -22
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.5.0", "version": "0.6.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev", "dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -9,26 +9,30 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "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}", "unit": "bun test {src,packages}",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math", "generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons", "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": { "dependencies": {
"@gitbook/api": "^0.81.0", "@gitbook/api": "^0.96.0",
"@gitbook/cache-do": "workspace:*", "@gitbook/cache-do": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1", "@sentry/nextjs": "8.35.0",
"@sindresorhus/fnv1a": "^3.1.0", "@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1", "@upstash/redis": "^1.27.1",
"ajv": "^8.12.0", "ajv": "^8.12.0",
"assert-never": "^1.2.1", "assert-never": "^1.2.1",
@@ -40,40 +44,38 @@
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"katex": "^0.16.9", "katex": "^0.16.9",
"mathjax": "^3.2.2", "mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
"next": "14.2.15", "micromark": "^4.0.1",
"micromark-extension-gfm": "^3.0.0",
"next": "14.2.23",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nuqs": "^1.17.4", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.0", "p-map": "^7.0.0",
"parse-cache-control": "^1.0.1", "parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hotkeys-hook": "^4.4.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",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rison": "^0.1.1", "rison": "^0.1.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shiki": "^1.23.1", "shiki": "^1.27.2",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2", "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": { "devDependencies": {
"@argos-ci/playwright": "^2.0.0", "@argos-ci/playwright": "^3.10.0",
"@cloudflare/next-on-pages": "^1.13.5", "@cloudflare/next-on-pages": "1.13.7",
"@cloudflare/workers-types": "^4.20240725.0", "vercel": "^39.3.0",
"@playwright/test": "^1.42.1", "@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5", "@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20", "@types/node": "^20",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4", "@types/parse-cache-control": "^1.0.4",
+9 -18
View File
@@ -1,27 +1,18 @@
import { import * as Sentry from '@sentry/browser';
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
// To tree shake default integrations that we don't use Sentry.init({
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false,
dsn, dsn,
integrations: [], release: process.env.SENTRY_RELEASE,
sampleRate: 0.1,
enableTracing: false, // 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: () => { beforeSendTransaction: () => {
return null; 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,13 +1,16 @@
import { NextRequest } from 'next/server';
import { import {
verifyImageSignature,
resizeImage, resizeImage,
CloudflareImageOptions,
imagesResizingSignVersion,
checkIsSizableImageURL, checkIsSizableImageURL,
} from '@/lib/images'; CloudflareImageOptions,
import { parseImageAPIURL } from '@/lib/urls'; CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature,
parseImageAPIURL,
} from '@v2/lib/images';
import { NextRequest, NextResponse } from 'next/server';
import { getHost } from '@/lib/links';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -20,12 +23,15 @@ export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url'); let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign'); 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) { if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 }); 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); const url = parseImageAPIURL(urlParam);
// Check again if the image can be sized, even though we checked when rendering the Image component // Check again if the image can be sized, even though we checked when rendering the Image component
@@ -35,17 +41,22 @@ export async function GET(request: NextRequest) {
return new Response('Invalid url parameter', { status: 400 }); 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 // Verify the signature
const verified = await verifyImageSignature(url, { signature }); const verified = await verifyImageSignature(
{
url,
host: await getHost(),
},
{ signature, version: signatureVersion },
);
if (!verified) { if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 }); 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. // Cloudflare-specific options are in the cf object.
const options: CloudflareImageOptions = { const options: CloudflareImageOptions = {
fit: 'scale-down', fit: 'scale-down',
@@ -90,6 +101,24 @@ export async function GET(request: NextRequest) {
return response; return response;
} catch (error) { } catch (error) {
// Redirect to the original image if resizing fails // 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,200 +0,0 @@
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } 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';
/**
* Fetch and render a page.
*/
export default async function Page(props: {
params: PagePathParams;
searchParams: { fallback?: string };
}) {
const { params, searchParams } = props;
const {
content: contentPointer,
contentTarget,
sections,
space,
site,
customization,
pages,
page,
ancestors,
document,
} = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
redirectOnFallback: true,
});
const linksContext: PageHrefContext = {};
const rawPathname = getPathnameParam(params);
if (!page) {
const pathname = normalizePathname(rawPathname);
if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(absoluteHref(pathname));
} else {
notFound();
}
} else if (getPagePath(pages, page) !== rawPathname) {
redirect(pageHref(pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withFullPageCover = !!(
page.cover &&
page.layout.cover &&
page.layout.coverSize === 'full'
);
const withPageFeedback = customization.feedback.enabled;
const contentRefContext: ContentRefContext = {
siteContext: contentPointer,
space,
revisionId: contentTarget.revisionId,
pages,
page,
};
const withSections = Boolean(sections && sections.list.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return (
<>
{withFullPageCover && page.cover ? (
<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}
<PageBody
space={space}
pointer={contentPointer}
contentTarget={contentTarget}
customization={customization}
context={contentRefContext}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/>
</div>
<React.Suspense fallback={null}>
<PageClientLayout withSections={withSections} />
</React.Suspense>
</>
);
}
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
? 'dark light'
: 'light dark'
: customization.themes.default,
};
}
export async function generateMetadata({
params,
searchParams,
}: {
params: PagePathParams;
searchParams: { fallback?: string };
}): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
});
if (!page) {
notFound();
}
return {
title: [page.title, getContentTitle(space, customization, site ?? null)]
.filter(Boolean)
.join(' | '),
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: absoluteHref(getPagePath(pages, page), true).replace(/\/+$/, ''),
},
openGraph: {
images: [
customization.socialPreview.url ??
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
],
},
robots:
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
};
}
/**
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
*/
async function getPageDataWithFallback(args: {
pagePathParams: PagePathParams;
searchParams: { fallback?: string };
redirectOnFallback?: boolean;
}) {
const { pagePathParams, searchParams, redirectOnFallback = false } = args;
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams);
let page = targetPage;
const canFallback = !!searchParams.fallback;
if (!page && canFallback) {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(pageHref(pages, rootPage?.page));
}
page = rootPage?.page;
}
return {
...otherPageData,
pages,
page,
};
}
@@ -1,155 +0,0 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
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 { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector';
import { fetchContentData } from '../fetch';
export const runtime = 'edge';
/**
* Layout when rendering the content.
*/
export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props;
const nonce = getContentSecurityPolicyNonce();
const {
content,
space,
contentTarget,
customization,
pages,
site,
spaces,
ancestors,
scripts,
sections,
} = await fetchContentData();
ReactDOM.preconnect(api().client.endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
scripts.forEach(({ script }) => {
ReactDOM.preload(script, {
as: 'script',
nonce,
});
});
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}
>
{children}
</SpaceLayout>
{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}
<RocketLoaderDetector nonce={nonce} />
<AdminToolbar space={space} content={content} />
</ClientContexts>
);
}
export async function generateViewport(): Promise<Viewport> {
const { customization } = await fetchContentData();
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
? 'dark light'
: 'light dark'
: customization.themes.default,
};
}
export async function generateMetadata(): Promise<Metadata> {
const { space, site, customization } = await fetchContentData();
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()),
icons: {
icon: [
{
url:
customIcon?.light ??
absoluteHref('~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),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
],
},
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
};
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
function getQueryStringTheme() {
const headersList = headers();
const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) {
return null;
}
return queryStringTheme === 'light'
? CustomizationThemeMode.Light
: CustomizationThemeMode.Dark;
}
@@ -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) : [],
);
}
@@ -1,223 +0,0 @@
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 { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
/**
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, site } = await fetchPageData(params);
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());
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',
};
let colors = {
background: baseColors[theme],
gradient: customization.styling.primaryColor[theme],
title: customization.styling.primaryColor[theme],
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);
let gridAsset = useLightTheme ? gridBlack : gridWhite;
switch (customization.header.preset) {
case CustomizationHeaderPreset.Custom:
colors = {
background: customization.header.backgroundColor?.[theme] || colors.background,
gradient: customization.header.linkColor?.[theme] || colors.gradient,
title: customization.header.linkColor?.[theme] || colors.title,
body: colorContrast(
customization.header.backgroundColor?.[theme] || colors.background,
[baseColors.light, baseColors.dark],
),
};
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
break;
case CustomizationHeaderPreset.Bold:
colors = {
background: customization.styling.primaryColor[theme],
gradient: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
title: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
body: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
};
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
break;
}
const favicon = function () {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw={tcls('mr-4')}
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw={tcls('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')}
/>
);
};
return new ImageResponse(
(
<div
tw={tcls(
'justify-between',
'p-20',
'relative',
'w-full',
'h-full',
'flex',
'flex-col',
`bg-[${colors.background}]`,
`text-[${colors.body}]`,
)}
>
{/* Gradient */}
<div
tw={tcls('absolute', 'inset-0')}
style={{
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
opacity: 0.5,
}}
></div>
{/* Grid */}
<img
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
src={gridAsset}
alt="Grid"
/>
{/* Logo */}
{customization.header.logo ? (
<img
alt="Logo"
height={60}
src={
useLightTheme
? customization.header.logo.light
: customization.header.logo.dark
}
/>
) : (
<div tw={tcls('flex')}>
{favicon()}
<h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
</div>
)}
{/* Title and description */}
<div tw={tcls('flex', 'flex-col')}>
<h1
tw={tcls(
'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'}
</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>
) : null}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: interRegular,
weight: 400,
style: 'normal',
},
{
name: 'Inter',
data: interBold,
weight: 700,
style: 'normal',
},
],
},
);
}
-145
View File
@@ -1,145 +0,0 @@
import { RevisionPage } from '@gitbook/api';
import { redirect } from 'next/navigation';
import {
getRevisionPageByPath,
getDocument,
getSpaceContentData,
getSiteData,
getSiteRedirectBySource,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
export interface PagePathParams {
pathname?: string[];
}
export interface PageIdParams {
pageId: string;
}
/**
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = getSiteContentPointer();
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
getSpaceContentData(content, content.siteShareKey),
getSiteData(content),
]);
// we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
return {
content,
contentTarget,
space: spaceRelativeToParent ?? space,
pages,
site,
sections,
spaces,
shareKey: content.siteShareKey,
customization,
scripts,
ancestors: [],
};
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const contentData = await fetchContentData();
const page = await resolvePage({
organizationId: contentData.space.organization,
siteId: contentData.site.id,
spaceId: contentData.contentTarget.spaceId,
revisionId: contentData.contentTarget.revisionId,
pages: contentData.pages,
shareKey: contentData.shareKey,
params,
});
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
: null;
return {
...contentData,
...page,
document,
};
}
/**
* Resolve a page from the params.
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(input: {
organizationId: string;
siteId: string;
spaceId: string;
revisionId: string;
shareKey: string | undefined;
pages: RevisionPage[];
params: PagePathParams | PageIdParams;
}) {
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
if ('pageId' in params) {
return resolvePageId(pages, params.pageId);
}
const rawPathname = getPathnameParam(params);
const pathname = normalizePathname(rawPathname);
// When resolving a page, we use the lowercased pathname
const page = resolvePagePath(pages, pathname);
if (page) {
return page;
}
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
if (rawPathname.length <= 512) {
// If page can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
if (resolved) {
return resolvePageId(pages, resolved.id);
}
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
const resolvedSiteRedirect = await getSiteRedirectBySource({
organizationId,
siteId,
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
siteShareKey: input.shareKey,
});
if (resolvedSiteRedirect) {
return redirect(resolvedSiteRedirect.target);
}
}
return undefined;
}
/**
* Get the page path from the params.
*/
export function getPathnameParam(params: PagePathParams): string {
const { pathname } = params;
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
}
/**
* Normalize the URL pathname into the format used in the revision page path.
*/
export function normalizePathname(pathname: string) {
return pathname.toLowerCase();
}
@@ -1,38 +0,0 @@
import { SpaceIntegrationScript } from '@gitbook/api';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteData, getSpaceCustomization } from '@/lib/api';
import { getSiteOrSpacePointerForPDF } from './pointer';
/**
* Layout to be used for the site root. It fetches the customization data for the
* site or space and initializes the CustomizationRootLayout with it.
*/
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer
? getSiteData(pointer)
: getSpaceLayoutData(pointer.spaceId));
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
);
}
/**
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData(spaceId: string) {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(spaceId),
[] as SpaceIntegrationScript[],
]);
return {
customization,
scripts,
};
}
@@ -1,17 +0,0 @@
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
/**
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
* for PDF export of in-app private spaces.
*
* This function returns the pointer depending on the context.
*/
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
try {
return getSiteContentPointer();
} catch (error) {
return getSpacePointer();
}
}
@@ -0,0 +1,8 @@
import { SitePageSkeleton } from '@/components/SitePage';
/**
* Placeholder when loading a page.
*/
export default function PageSkeleton() {
return <SitePageSkeleton />;
}
@@ -0,0 +1,5 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -0,0 +1,56 @@
import { Metadata, Viewport } from 'next';
import React from 'react';
import {
generateSitePageMetadata,
generateSitePageViewport,
SitePage,
PagePathParams,
} from '@/components/SitePage';
import { getSiteContentPointer } from '@/lib/pointer';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
type PageProps = {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
};
/**
* Fetch and render a page.
*/
export default async function Page(props: PageProps) {
const sitePageProps = await getSitePageProps(props);
return <SitePage {...sitePageProps} />;
}
export async function generateViewport(): Promise<Viewport> {
const pointer = await getSiteContentPointer();
const context = await fetchV1ContextForSitePointer(pointer);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const sitePageProps = await getSitePageProps(props);
return generateSitePageMetadata(sitePageProps);
}
async function getSitePageProps(props: PageProps) {
const { params: rawParams, searchParams: rawSearchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const pointer = await getSiteContentPointer();
const context = await fetchV1ContextForSitePointer(pointer);
return {
context,
pageParams: params,
redirectOnFallback: true,
fallback: !!searchParams.fallback,
};
}
@@ -0,0 +1,53 @@
import { getThemeFromMiddleware } from '@v2/lib/middleware';
import { Metadata, Viewport } from 'next';
import React from 'react';
import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
SiteLayout,
} from '@/components/SiteLayout';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { getSiteContentPointer } from '@/lib/pointer';
import { shouldTrackEvents } from '@/lib/tracking';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* Layout when rendering the content.
*/
export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props;
const nonce = await getContentSecurityPolicyNonce();
const context = await fetchLayoutData();
const queryStringTheme = await getThemeFromMiddleware();
return (
<SiteLayout
context={context}
nonce={nonce}
forcedTheme={queryStringTheme}
withTracking={await shouldTrackEvents()}
>
{children}
</SiteLayout>
);
}
export async function generateViewport(): Promise<Viewport> {
const context = await fetchLayoutData();
return generateSiteLayoutViewport(context);
}
export async function generateMetadata(): Promise<Metadata> {
const context = await fetchLayoutData();
return generateSiteLayoutMetadata(context);
}
async function fetchLayoutData() {
const pointer = await getSiteContentPointer();
return fetchV1ContextForSitePointer(pointer);
}
@@ -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/sites';
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,9 +1,9 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api'; import { getSite } from '@/lib/api';
import { absoluteHref } from '@/lib/links'; import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo'; import { isSiteIndexable } from '@/lib/seo';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -11,17 +11,14 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space. * Generate a robots.txt for the current space.
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer(); const pointer = await getSiteContentPointer();
const [site, space] = await Promise.all([ const site = await getSite(pointer.organizationId, pointer.siteId);
getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey),
]);
const lines = [ const lines = [
`User-agent: *`, `User-agent: *`,
'Disallow: /~gitbook/', 'Disallow: /~gitbook/',
...(isSpaceIndexable({ space, site }) ...((await isSiteIndexable(site))
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`] ? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]), : [`Disallow: /`]),
]; ];
const content = lines.join('\n'); 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 { getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
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);
}
@@ -3,11 +3,10 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import React from 'react'; import React from 'react';
import { getSite, getSiteData, getSpace } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis'; import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils'; import { fetchV1ContextForSitePointer } from '@/lib/v1';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -35,20 +34,15 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url); const options = getOptions(req.url);
const size = SIZES[options.size]; const size = SIZES[options.size];
const pointer = getSiteContentPointer(); const pointer = await getSiteContentPointer();
const spaceId = pointer.spaceId;
const [space, { customization }] = await Promise.all([ const { site, customization } = await fetchV1ContextForSitePointer(pointer);
getSpace(spaceId, pointer.siteShareKey), const contentTitle = site.title;
getSiteData(pointer),
]);
const site = await getSite(pointer.organizationId, pointer.siteId);
const contentTitle = getContentTitle(space, customization, site);
return new ImageResponse( return new ImageResponse(
( (
<div <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={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -62,7 +56,7 @@ export async function GET(req: NextRequest) {
size.textSize, size.textSize,
'font-bold', 'font-bold',
'tracking-tight', 'tracking-tight',
options.theme === 'light' ? 'text-gray-900' : 'text-white', options.theme === 'light' ? 'text-black' : 'text-white',
)} )}
> >
{'emoji' in customization.favicon {'emoji' in customization.favicon
@@ -0,0 +1,232 @@
import { CustomizationHeaderPreset } from '@gitbook/api';
import { colorContrast } from '@gitbook/colors';
import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { PageIdParams, fetchPageData } from '@/components/SitePage';
import { googleFontsMap } from '@/fonts';
import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { filterOutNullable } from '@/lib/typescript';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
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: Promise<PageIdParams> }) {
const pointer = await getSiteContentPointer();
const baseContext = await fetchV1ContextForSitePointer(pointer);
const { context, pageTarget } = await fetchPageData(baseContext, await params);
const { customization, site } = context;
const page = pageTarget?.page;
// If user configured a custom social preview, we redirect to it.
if (customization.socialPreview.url) {
redirect(customization.socialPreview.url);
}
// Compute all text to load only the necessary fonts
const contentTitle = customization.header.logo ? '' : site.title;
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' };
let colors = {
background: baseColors[theme],
gradient: customization.styling.primaryColor[theme],
title: customization.styling.primaryColor[theme],
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
};
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;
switch (customization.header.preset) {
case CustomizationHeaderPreset.Custom:
colors = {
background: customization.header.backgroundColor?.[theme] || colors.background,
gradient: customization.header.linkColor?.[theme] || colors.gradient,
title: customization.header.linkColor?.[theme] || colors.title,
body: colorContrast(
customization.header.backgroundColor?.[theme] || colors.background,
[baseColors.light, baseColors.dark],
),
};
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
break;
case CustomizationHeaderPreset.Bold:
colors = {
background: customization.styling.primaryColor[theme],
gradient: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
title: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
body: colorContrast(customization.styling.primaryColor[theme], [
baseColors.light,
baseColors.dark,
]),
};
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
break;
}
const favicon = await (async () => {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw="mr-4"
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw="text-4xl mr-4">
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span>
);
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={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`}
style={{
fontFamily,
}}
>
{/* Gradient */}
<div
tw="absolute inset-0"
style={{
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
opacity: 0.5,
}}
></div>
{/* Grid */}
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
{/* Logo */}
{customization.header.logo ? (
<img
alt="Logo"
height={60}
src={
useLightTheme
? customization.header.logo.light
: customization.header.logo.dark
}
/>
) : (
<div tw="flex">
{favicon}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
</div>
)}
{/* Title and description */}
<div tw="flex flex-col">
<h1
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
>
{pageTitle}
</h1>
{pageDescription ? (
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
) : null}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: fonts.length ? fonts : undefined,
},
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { captureException } from '@sentry/nextjs'; import { captureException, withScope } from '@sentry/nextjs';
import React from 'react'; import React from 'react';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -15,7 +15,12 @@ export default function ErrorPage(props: {
const language = useLanguage(); const language = useLanguage();
React.useEffect(() => { React.useEffect(() => {
captureException(error); withScope((scope) => {
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
}, [error]); }, [error]);
return ( return (
@@ -1,6 +1,6 @@
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
/** /**
* Layout to be used for the site root. It fetches the customization data for the site * Layout to be used for the site root. It fetches the customization data for the site
@@ -9,8 +9,8 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) { export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
const pointer = getSiteContentPointer(); const pointer = await getSiteContentPointer();
const { customization } = await getSiteData(pointer); const { customization } = await fetchV1ContextForSitePointer(pointer);
return ( return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout> <CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -133,7 +133,7 @@ export function PageControlButtons(props: {
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-lg', 'text-lg',
'text-dark/6', 'text-tint',
'px-6', 'px-6',
'py-2', 'py-2',
'bg-slate-100', 'bg-slate-100',
@@ -0,0 +1,24 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { getV1ContextForPDF } from './pointer';
/**
* Layout to be used for the site root. It fetches the customization data for the
* site or space and initializes the CustomizationRootLayout with it.
*/
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const context = await getV1ContextForPDF();
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomizationForSpace()
}
>
{children}
</CustomizationRootLayout>
);
}
@@ -3,10 +3,15 @@ import {
Revision, Revision,
RevisionPageDocument, RevisionPageDocument,
RevisionPageGroup, RevisionPageGroup,
RevisionPageType,
SiteCustomizationSettings, SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { GitBookSpaceContext } from '@v2/lib/context';
import { getPageDocument } from '@v2/lib/data';
import { GitBookSpaceLinker } from '@v2/lib/links';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import * as React from 'react'; import * as React from 'react';
@@ -16,37 +21,27 @@ import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types'; import { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server'; import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate'; import { tString } from '@/intl/translate';
import { import { getPagePDFContainerId, getAbsoluteHref } from '@/lib/links';
getDocument,
getSpace,
getSpaceCustomization,
getSpaceContentData,
getSiteData,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages'; import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls'; import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
import { defaultCustomizationForSpace } from '@/lib/utils';
import './pdf.css'; import './pdf.css';
import { PageControlButtons } from './PageControlButtons'; import { PageControlButtons } from './PageControlButtons';
import { getSiteOrSpacePointerForPDF } from './pointer'; import { getV1ContextForPDF } from './pointer';
import { PrintButton } from './PrintButton'; import { PrintButton } from './PrintButton';
const DEFAULT_LIMIT = 100; const DEFAULT_LIMIT = 100;
export const runtime = 'edge'; export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF(); const context = await getV1ContextForPDF();
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
]);
return { return {
title: customization.title ?? space.title, title: 'site' in context ? context.site.title : context.space.title,
robots: 'noindex, nofollow', robots: 'noindex, nofollow',
}; };
} }
@@ -54,30 +49,49 @@ export async function generateMetadata(): Promise<Metadata> {
/** /**
* Render a space as a standalone HTML page that can be printed as a PDF. * 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 } }) { export default async function PDFHTMLOutput(props: {
const pointer = getSiteOrSpacePointerForPDF(); searchParams: Promise<{ [key: string]: string }>;
}) {
const searchParams = new URLSearchParams(props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams)); const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params // 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(); currentPDFUrl += '?' + searchParams.toString();
// Load the content, // Fetch the context
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([ const baseContext = await getV1ContextForPDF();
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined), const customization =
]); 'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
// Compute the pages to render // Compute the pages to render
const { pages, total } = selectPages(rootPages, pdfParams); const { pages, total } = selectPages(baseContext.pages, pdfParams);
const pageIds = pages.map( 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), // Build a linker that create anchor links for the pages rendered in the PDF page.
const linker: GitBookSpaceLinker = {
...baseContext.linker,
toPathForPage(input) {
if (pages.some((p) => p.page.id === input.page.id)) {
return '#' + getPagePDFContainerId(input.page, input.anchor);
} else {
if (input.page.type === RevisionPageType.Group) {
return '#';
}
// Use an absolute URL to the page
return input.page.urls.app;
}
},
};
const context: GitBookSpaceContext = {
...baseContext,
linker,
}; };
return ( return (
@@ -86,17 +100,16 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}> <div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a <a
title={tString(language, 'pdf_goback')} title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? absoluteHref('')} href={pdfParams.back ?? context.linker.toAbsoluteURL('')}
className={tcls( className={tcls(
'flex', 'flex',
'flex-row', 'flex-row',
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-sm', 'text-sm',
'text-dark/6', 'text-tint',
'hover:text-primary', 'hover:text-primary',
'p-4', 'p-4',
'dark:text-light/5',
'rounded-full', 'rounded-full',
'bg-white', 'bg-white',
'shadow-sm', 'shadow-sm',
@@ -119,10 +132,9 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-sm', 'text-sm',
'text-dark/6', 'text-tint',
'hover:text-primary', 'hover:text-primary',
'p-4', 'p-4',
'dark:text-light/5',
'rounded-full', 'rounded-full',
'bg-white', 'bg-white',
'shadow-sm', 'shadow-sm',
@@ -143,36 +155,31 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
limit={pdfParams.limit ?? DEFAULT_LIMIT} limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={ trademark={
customization.trademark.enabled ? ( customization.trademark.enabled ? (
<TrademarkLink space={space} customization={customization} /> <TrademarkLink
space={context.space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
) : null ) : null
} }
/> />
{pdfParams.only ? null : <PDFSpaceIntro space={space} customization={customization} />} {pdfParams.only ? null : (
<PDFSpaceIntro space={context.space} customization={customization} />
)}
{pages.map(({ page }) => {pages.map(({ page }) =>
page.type === 'group' ? ( page.type === 'group' ? (
<PDFPageGroup key={page.id} space={space} page={page} /> <PDFPageGroup key={page.id} space={context.space} page={page} />
) : ( ) : (
<React.Suspense <React.Suspense
key={page.id} key={page.id}
fallback={ fallback={
<PrintPage id={pagePDFContainerId(page)}> <PrintPage id={getPagePDFContainerId(page)}>
<p>Loading...</p> <p>Loading...</p>
</PrintPage> </PrintPage>
} }
> >
<PDFPageDocument <PDFPageDocument page={page} context={context} />
space={space}
page={page}
refContext={{
siteContext: 'siteId' in pointer ? pointer : null,
space,
revisionId: contentTarget.revisionId,
pages: rootPages,
page,
...linksContext,
}}
/>
</React.Suspense> </React.Suspense>
), ),
)} )}
@@ -201,7 +208,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props; const { page } = props;
return ( return (
<PrintPage id={pagePDFContainerId(page)}> <PrintPage id={getPagePDFContainerId(page)}>
<div <div
className={tcls( className={tcls(
'break-before-page', 'break-before-page',
@@ -220,16 +227,15 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
} }
async function PDFPageDocument(props: { async function PDFPageDocument(props: {
space: Space;
page: RevisionPageDocument; page: RevisionPageDocument;
refContext: ContentRefContext; context: GitBookSpaceContext;
}) { }) {
const { space, page, refContext } = props; const { page, context } = props;
const { space } = context;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null; const document = await getPageDocument(context.dataFetcher, space.id, page);
return ( return (
<PrintPage id={pagePDFContainerId(page)}> <PrintPage id={getPagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1> <h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? ( {page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p> <p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
@@ -238,17 +244,15 @@ async function PDFPageDocument(props: {
{document ? ( {document ? (
<DocumentView <DocumentView
document={document} document={document}
style={'mt-6 space-y-6'} style="mt-6 space-y-6"
blockStyle={['max-w-full']} blockStyle="max-w-full"
context={{ context={{
mode: 'print', mode: 'print',
content: { contentContext: {
spaceId: space.id, ...context,
revisionId: refContext.revisionId, page,
}, },
contentRefContext: refContext, getId: (id) => getPagePDFContainerId(page, id),
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
}} }}
/> />
) : null} ) : null}
@@ -0,0 +1,33 @@
import { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
import { fetchV1ContextForSitePointer, fetchV1ContextForSpacePointer } from '@/lib/v1';
/**
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
* for PDF export of in-app private spaces.
*
* This function returns the pointer depending on the context.
*/
export async function getSiteOrSpacePointerForPDF(): Promise<
SiteContentPointer | SpaceContentPointer
> {
try {
return await getSiteContentPointer();
} catch (error) {
return getSpacePointer();
}
}
/**
* Get the context for the PDF pointer.
*/
export async function getV1ContextForPDF(): Promise<GitBookSiteContext | GitBookSpaceContext> {
const pointer = await getSiteOrSpacePointerForPDF();
return 'siteId' in pointer && pointer.siteId
? await fetchV1ContextForSitePointer(pointer)
: await fetchV1ContextForSpacePointer(pointer);
}
@@ -1,8 +1,8 @@
import { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { GitBookSiteContext } from '@v2/lib/context';
import { headers } from 'next/headers';
import React from 'react'; import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
@@ -10,37 +10,10 @@ import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Tool
import { DateRelative } from '../primitives'; import { DateRelative } from '../primitives';
interface AdminToolbarProps { interface AdminToolbarProps {
content: SiteContentPointer; context: GitBookSiteContext;
space: Space;
} }
/** function ToolbarLayout(props: { children: React.ReactNode }) {
* 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;
}
return ( return (
<div <div
className={tcls( className={tcls(
@@ -51,87 +24,125 @@ export function AdminToolbar(props: AdminToolbarProps) {
'transform', 'transform',
'-translate-x-1/2', '-translate-x-1/2',
'rounded-full', 'rounded-full',
'bg-dark-1/9',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'shadow-lg', 'shadow-lg',
'min-h-10', 'min-h-10',
'min-w-40', 'min-w-40',
'p-2', 'p-2',
'max-w-md', 'max-w-md',
'border-dark-1', 'border-tint-12/1',
'backdrop-blur-sm', 'backdrop-blur-md',
)} )}
> >
<React.Suspense fallback={null}>{toolbar}</React.Suspense> <React.Suspense fallback={null}>{props.children}</React.Suspense>
</div> </div>
); );
} }
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) { /**
const { spaceId, changeRequestId } = props; * Toolbar with information for the content admin when previewing a revision or change-request.
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
const mode = await headers().get('x-gitbook-mode');
const changeRequest = await getChangeRequest(spaceId, changeRequestId); 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 (context.changeRequest) {
return <ChangeRequestToolbar context={context} />;
}
if (context.revisionId !== context.space.revision) {
return <RevisionToolbar context={context} />;
}
return null;
}
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { space, changeRequest } = context;
if (!changeRequest) {
return null;
}
return ( return (
<Toolbar> <ToolbarLayout>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}> <Toolbar>
<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>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}> <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> </ToolbarButton>
<RefreshChangeRequestButton <ToolbarBody>
spaceId={spaceId} <p>
changeRequestId={changeRequestId} #{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
revisionId={changeRequest.revision} </p>
updatedAt={new Date(changeRequest.updatedAt).getTime()} <p className="text-xs text-tint-2 dark:text-tint-11">
/> Change request updated <DateRelative value={changeRequest.updatedAt} />
</ToolbarButtonGroups> </p>
</Toolbar> </ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={space.id}
changeRequestId={changeRequest.id}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
); );
} }
async function RevisionToolbar(props: { spaceId: string; revisionId: string }) { async function RevisionToolbar(props: { context: GitBookSiteContext }) {
const { spaceId, revisionId } = props; const { context } = props;
const { space, revisionId } = context;
const revision = await getRevision(spaceId, revisionId, { const revision = await context.dataFetcher.getRevision({
spaceId: space.id,
revisionId,
metadata: true, metadata: true,
}); });
return ( return (
<Toolbar> <ToolbarLayout>
<ToolbarButton title="Open in application" href={revision.urls.app}> <Toolbar>
<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>
<ToolbarButton title="Open in application" href={revision.urls.app}> <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> </ToolbarButton>
{revision.git?.url ? ( <ToolbarBody>
<ToolbarButton title="Open git commit" href={revision.git.url}> <p>
<Icon Revision created <DateRelative value={revision.createdAt} />
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'} </p>
className="size-4" {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> </ToolbarButton>
) : null} {revision.git?.url ? (
</ToolbarButtonGroups> <ToolbarButton title="Open git commit" href={revision.git.url}>
</Toolbar> <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', 'py-1',
'rounded-full', 'rounded-full',
'truncate', 'truncate',
'text-light', 'text-tint-1',
'dark:text-light', 'dark:text-tint-12',
)} )}
> >
{children} {children}
@@ -50,12 +50,11 @@ export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
'gap-1', 'gap-1',
'text-sm', 'text-sm',
'rounded-full', 'rounded-full',
'hover:bg-dark-1',
'hover:text-white',
'truncate', 'truncate',
'text-light', 'text-tint-1', // Equal to text-tint-strong in dark mode
'dark:text-light', 'dark:text-tint-12', // Equal to text-tint-strong in dark mode
'dark:hover:bg-dark-2', 'hover:bg-tint-12',
'dark:hover:bg-tint-1',
'hover:shadow-lg', 'hover:shadow-lg',
'cursor-pointer', 'cursor-pointer',
)} )}
+38 -41
View File
@@ -1,12 +1,21 @@
'use client'; 'use client';
import { SiteAds, SiteAdsStatus } from '@gitbook/api'; import {
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import * as React from 'react'; import * as React from 'react';
import { t, useLanguage } from '@/intl/client'; import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind'; import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd'; 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. * Zone ID provided by BuySellAds for the preview.
@@ -28,46 +37,34 @@ export function Ad({
}: { }: {
zoneId: string | null; zoneId: string | null;
spaceId: string; spaceId: string;
placement: string; placement: SiteInsightsAdPlacement;
ignore: boolean; ignore: boolean;
style?: ClassValue; style?: ClassValue;
siteAdsStatus?: SiteAds['status']; siteAdsStatus?: SiteAds['status'];
mode?: 'classic' | 'auto' | 'cover'; mode?: 'classic' | 'auto' | 'cover';
}) { }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false); const [ad, setAd] = React.useState<
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined); { children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
>(undefined);
const trackEvent = useTrackEvent();
// Observe the container visibility // Track display of the ad
React.useEffect(() => { React.useEffect(() => {
if (!containerRef.current) { if (ad?.insightsAd) {
return; trackEvent({
type: 'ad_display',
ad: ad.insightsAd,
});
} }
}, [ad, trackEvent]);
const observer = new IntersectionObserver( const hasBeenInViewport = useHasBeenInViewport(containerRef, { threshold: 0.1 });
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
}
},
{
root: null,
rootMargin: '0px',
threshold: 0.1,
},
);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, []);
// When the container is visible, // When the container is visible,
// track an impression on the ad and fetch it // track an impression on the ad and fetch it
React.useEffect(() => { React.useEffect(() => {
if (!visible) { if (!hasBeenInViewport) {
return; return;
} }
@@ -111,13 +108,13 @@ export function Ad({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]); }, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]);
return ( return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed"> <div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? ( {ad ? (
<> <>
{ad} {ad.children}
<AdSponsoredLink spaceId={spaceId} /> <AdSponsoredLink spaceId={spaceId} />
</> </>
) : null} ) : null}
@@ -128,6 +125,7 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) { function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props; const { spaceId } = props;
const language = useLanguage(); const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com'); const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content'); viaUrl.searchParams.set('utm_source', 'content');
@@ -135,19 +133,18 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId); viaUrl.searchParams.set('utm_campaign', spaceId);
return ( return (
<p <p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
className={tcls( <Link
'mt-2', target="_blank"
'mr-2', href={viaUrl.toString()}
'text-xs', className={tcls('hover:underline')}
'text-right', insights={{
'text-dark/5', type: 'trademark_click',
'dark:text-light/5', placement: SiteInsightsTrademarkPlacement.Ad,
)} }}
> >
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
{t(language, 'sponsored_via_gitbook')} {t(language, 'sponsored_via_gitbook')}
</a> </Link>
</p> </p>
); );
} }
@@ -1,55 +1,72 @@
import { SiteInsightsAd } from '@gitbook/api';
import { GitBookBaseContext } from '@v2/lib/context';
import { getResizedImageURL } from '@v2/lib/images';
import * as React from 'react'; import * as React from 'react';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AdItem } from './types'; import { AdItem } from './types';
import { Link } from '../primitives';
/** /**
* Classic rendering for an ad. * Classic rendering for an ad.
*/ */
export function AdClassicRendering({ ad }: { ad: AdItem }) { export async function AdClassicRendering({
ad,
insightsAd,
context,
}: {
ad: AdItem;
insightsAd: SiteInsightsAd | null;
context: GitBookBaseContext;
}) {
const smallImgSrc =
'smallImage' in ad
? await getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
: null;
const logoSrc =
'logo' in ad
? await getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
: null;
return ( return (
<a <Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls( className={tcls(
'flex', 'flex',
'flex-col', 'flex-col',
'gap-4', 'gap-4',
'bg-light-2', 'bg-tint',
'text-dark/7', 'text-tint',
'dark:bg-dark-2', 'hover:text-tint-strong',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg', 'rounded-lg',
'p-4', 'p-4',
)} )}
href={ad.statlink} href={ad.statlink}
rel="sponsored noopener"
target="_blank"
> >
{'smallImage' in ad ? ( {smallImgSrc && 'smallImage' in ad ? (
<div> <div>
<img <img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
alt="Ads logo"
className={tcls('rounded-md')}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
</div> </div>
) : ( ) : logoSrc && 'logo' in ad ? (
<div <div
className={tcls('px-6', 'py-4', 'rounded-md')} className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }} style={{ backgroundColor: ad.backgroundColor }}
> >
<img <img alt="Ads logo" src={logoSrc} />
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
</div> </div>
)} ) : null}
<div className={tcls('flex', 'flex-col')}> <div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div> <div className={tcls('text-xs')}>{ad.description}</div>
</div> </div>
</a> </Link>
); );
} }
@@ -1,31 +1,52 @@
import { SiteInsightsAd } from '@gitbook/api';
import { hexToRgba } from '@gitbook/colors';
import { GitBookBaseContext } from '@v2/lib/context';
import { getResizedImageURL } from '@v2/lib/images';
import * as React from 'react'; import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AdCover } from './types'; import { AdCover } from './types';
import { Link } from '../primitives';
/** /**
* Cover rendering for an ad. * Cover rendering for an ad.
*/ */
export function AdCoverRendering({ ad }: { ad: AdCover }) { export async function AdCoverRendering({
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 }); ad,
insightsAd,
context,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
context: GitBookBaseContext;
}) {
const largeImage = await getResizedImageURL(context.imageResizer, ad.largeImage, {
width: 128,
dpr: 2,
});
return ( return (
<a <Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls( className={tcls(
'group/ad', 'group/ad',
'relative', 'relative',
'flex', 'flex',
'flex-col', 'flex-col',
'gap-4', 'gap-4',
'bg-light-2', 'bg-tint-subtle',
'text-dark/7', 'text-tint',
'dark:bg-dark-2', 'hover:text-tint-strong',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg', 'rounded-lg',
'p-4', 'p-4',
'overflow-hidden', 'overflow-hidden',
@@ -33,8 +54,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
)} )}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }} style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink} href={ad.statlink}
rel="sponsored noopener"
target="_blank"
> >
<div <div
className={tcls( className={tcls(
@@ -106,6 +125,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
backgroundColor: hexToRgba(ad.backgroundColor, 0.8), backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}} }}
/> />
</a> </Link>
); );
} }
@@ -1,7 +1,10 @@
'use server'; 'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { getV1BaseContext } from '@/lib/v1';
import { AdClassicRendering } from './AdClassicRendering'; import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering'; import { AdCoverRendering } from './AdCoverRendering';
import { AdPixels } from './AdPixels'; import { AdPixels } from './AdPixels';
@@ -20,7 +23,7 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */ /** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover'; mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */ /** Name of the placement for the ad */
placement: string; placement: SiteInsightsAdPlacement;
/** If true, we'll not track it as an impression */ /** If true, we'll not track it as an impression */
ignore: boolean; ignore: boolean;
} }
@@ -38,25 +41,38 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP. * and properly access user-agent and IP.
*/ */
export async function renderAd(options: FetchAdOptions) { export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic'; const baseContext = await getV1BaseContext();
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd(); const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) { if (!result || !result.ad.description || !result.ad.statlink) {
return null; return null;
} }
const { ad } = result; const { ad } = result;
return ( const insightsAd: SiteInsightsAd | null =
<> options.source === 'live'
{mode === 'classic' || !('callToAction' in ad) ? ( ? {
<AdClassicRendering ad={ad} /> placement: options.placement,
) : ( zoneId: options.zoneId,
<AdCoverRendering ad={ad} /> domain: 'company' in ad ? ad.company : '',
)} }
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null} : null;
</>
); return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={baseContext} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={baseContext} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
} }
async function fetchAd({ async function fetchAd({
@@ -64,7 +80,7 @@ async function fetchAd({
placement, placement,
ignore, ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> { }: 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`); const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`); url.searchParams.set('segment', `placement:${placement}`);
@@ -86,8 +102,8 @@ async function fetchAd({
return null; return null;
} }
function getPlaceholderAd(): { ad: AdItem; ip: string } { async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
const { ip } = getUserAgentAndIp(); const { ip } = await getUserAgentAndIp();
return { return {
ad: { ad: {
@@ -119,8 +135,8 @@ function getPlaceholderAd(): { ad: AdItem; ip: string } {
}; };
} }
function getUserAgentAndIp() { async function getUserAgentAndIp() {
const headersSet = headers(); const headersSet = await headers();
const ip = const ip =
headersSet.get('x-gitbook-ipv4') ?? headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ?? headersSet.get('x-gitbook-ip') ??
@@ -11,5 +11,8 @@ export async function hasContentBeenUpdated(props: {
revisionId: string; revisionId: string;
}) { }) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId); const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId; return changeRequest.revision !== props.revisionId;
} }

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