Compare commits

..

130 Commits

Author SHA1 Message Date
Samy Pessé 3fe3809416 Version Packages (#2415)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-24 15:54:23 +02:00
Samy Pessé 363d337a72 Cleanup GitHub workflows (#2554) 2024-10-24 14:50:39 +02:00
Amrit Kahlon f89b31c940 Upgrade to the new scalar client (#2539)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-10-24 14:40:33 +02:00
Samy Pessé 630c3e84dd Migrate to cloudflare/wrangler-action for deployment (#2549) 2024-10-24 14:30:13 +02:00
Samy Pessé d43202f137 Reduce bundle size of shiki (#2551) 2024-10-24 10:16:56 +02:00
Scott Cazan 0964043489 Use updated endpoint for page feedbacks (#2548) 2024-10-24 10:16:46 +02:00
Taran Vohra e9b31a545d Unify section tab styles with page item styles (#2552) 2024-10-24 13:28:15 +05:30
Greg Bergé 7f28a88374 Use transparent bg for secondary button (#2545) 2024-10-24 06:51:39 +02:00
Steven H 07ea45bf84 Remove deprecated synced block from GitBook Open (#2546) 2024-10-23 15:45:31 +01:00
Zeno Kapitein a86d35f721 Fixes variant selector for sites without variants (#2544) 2024-10-22 17:22:07 +02:00
Zeno Kapitein d6c28a0ff6 Update header styling of sections, variant selector, and button links (#2543) 2024-10-22 16:58:27 +02:00
Brett Jephson a7af3ca7bc Improving the new tabs (#2537) 2024-10-21 19:15:22 +01:00
Taran Vohra cda08a9843 Add support for search in a sections site (#2541) 2024-10-21 15:54:29 +05:30
Taran Vohra 7588774237 add all mode while searching site content (#2540) 2024-10-21 12:13:00 +05:30
Greg Bergé 683373b263 Fix CLS and others (#2536) 2024-10-20 12:55:37 +02:00
spastorelli 673ec8fb5d Update Placeholder ad & Sponsored by GitBook links (#2538) 2024-10-19 08:51:22 +02:00
spastorelli 63bacaa671 Ensure pending or in-review sites still get placeholder ads even without preview query param (#2535) 2024-10-18 17:08:54 +02:00
Samy Pessé e3a3d6a505 Improve perf perception and fix flickering in Ask answer (#2534) 2024-10-18 15:53:57 +02:00
Taran Vohra 175171dda1 Add siteSectionId to lookup result (#2533) 2024-10-18 16:53:15 +05:30
Taran Vohra a4b63b88c8 generate upto URL with 4 segments to support sites with sections (#2532) 2024-10-18 16:34:25 +05:30
Steven H c3675fdfe7 Add support for reusable content (#2531) 2024-10-18 10:59:46 +01:00
Brett Jephson 2fa08519b6 RND-5227: Site section tabs (#2530) 2024-10-18 09:47:53 +01:00
spastorelli 065627060b Show placeholder ad when site ads status is pending or in-review (#2529) 2024-10-17 19:20:34 +02:00
Greg Bergé 2982b6a808 Implement Stepper (#2528) 2024-10-17 09:52:45 +02:00
Greg Bergé c3f56ecd40 Return null if block not handled (#2527) 2024-10-16 22:12:54 +02:00
Zeno Kapitein fe9e6c1afd Update ogimage with new design (#2517)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-10-15 17:28:37 +02:00
Taran Vohra 73394ae929 Mock data for getSpaceCustomization and getSpaceIntegrationScripts (#2522) 2024-10-15 15:40:05 +05:30
Zeno Kapitein c754fc9ff4 Add automatic color contrast in site header, restyle search button (#2512) 2024-10-15 11:05:25 +02:00
Zeno Kapitein 1ca6208057 Update Playwright test for share links (#2523) 2024-10-15 10:53:58 +02:00
Greg Bergé cdfae4d4df Remove custom types for HeaderLink (#2521) 2024-10-14 13:33:01 +02:00
Samy Pessé c3da5469ca Add basic support for computed pages types (#2520) 2024-10-14 10:04:12 +02:00
Taran Vohra 79f2d8ba76 Refactor getContentPointer usage into getSiteContentPointer (#2519) 2024-10-14 12:07:14 +05:30
Taran Vohra 7b1bdb92a6 Remove getContentPointer usage from search (#2516) 2024-10-14 10:26:14 +05:30
Taran Vohra f9a075eed1 add support for sites in multi id mode (#2514) 2024-10-11 15:23:38 +05:30
Greg Bergé 32e4193557 Implement header link style (#2513) 2024-10-09 15:25:47 +02:00
Zeno Kapitein 133c3e7c0a Update Checkbox component (#2511) 2024-10-08 11:02:51 +02:00
Samy Pessé 6ce3cea682 Stop using KV cache backend for now, but also improves it for higher performances (#2510) 2024-10-03 15:46:58 +02:00
Greg Bergé 250e77d18b Fix synced blocks display (#2508) 2024-10-03 10:30:21 +02:00
Addison e3bfe006e9 Add badges section to readme (#2507) 2024-10-02 18:15:37 +09:00
Taran Vohra 76aeecdc68 Remove /v/ from e2e tests (#2505) 2024-10-01 12:04:08 +05:30
Samy Pessé 15d2ee3e76 Show captions for file blocks (#2506) 2024-09-30 17:54:25 +02:00
Taran Vohra b066db479f Bump the cache key for api.getPublishedContentByUrl (#2502) 2024-09-30 15:04:30 +02:00
Samy Pessé a69ff2789a Fix locationHint not being passed when purging the cache (#2504) 2024-09-30 14:45:17 +02:00
Taran Vohra 4df235d64a Add more visitor auth cookie combinations with /v/ for backwards compatibility (#2503) 2024-09-30 17:33:33 +05:30
Taran Vohra 11524457e3 URL alternative resolution criteria (#2497) 2024-09-30 14:18:44 +05:30
Addison 8f7364293e Update Documented on GB badge from encoded to uploaded icon (#2501) 2024-09-30 15:44:45 +08:00
Samy Pessé 56f5fa1fbe Lower usage of KV cache to 30% of tags (#2500) 2024-09-29 10:31:27 +02:00
Samy Pessé b075f0f7e9 Switch to aria-current="page" for the TOC items (#2499) 2024-09-27 12:54:11 +02:00
Samy Pessé 0ca32afe8d Lower usage of KV cache to 60% of tags (#2498) 2024-09-27 11:46:15 +02:00
Samy Pessé 32c2f257a9 Lower usage of KV cache to 80% of tags (#2496) 2024-09-26 19:28:25 +02:00
Brett Jephson a7066ccbe1 Fix: mobile navigation scroll (#2486)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-09-26 14:42:09 +01:00
Taran Vohra 09248e0efe Revert "generate upto URL with 3 segments to support the new site url format" (#2494) 2024-09-26 18:29:01 +05:30
Taran Vohra e2ef22fc5f generate upto URL with 3 segments to support the new site url format (#2493)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-09-26 17:16:22 +05:30
Samy Pessé 75f825e4f8 Disable KV cache for 5% of the cache tags (#2490) 2024-09-26 10:23:25 +02:00
Samy Pessé 9b8d5198e4 Experiment with lower DO billable duration by using direct RPC methods (#2492) 2024-09-25 23:57:43 +02:00
Samy Pessé e09f747727 Refresh change request cache when click refresh button (#2489) 2024-09-25 16:15:42 +02:00
Samy Pessé 16e6171750 Cache embeds for faster rendering (#2488) 2024-09-25 13:33:40 +02:00
Samy Pessé f885e887ea Improve change request previews: consistency and toolbar (#2487) 2024-09-25 12:54:15 +02:00
Samy Pessé 5096f7fbc5 Disable KV cache for docs.gitbook.com and change requests (#2485) 2024-09-25 11:53:53 +02:00
Brett Jephson f16560c1ee Scroll offset (#2484) 2024-09-25 08:52:44 +01:00
Samy Pessé d0f4860759 Refresh icons urls to purge cache of 500 (#2483) 2024-09-24 18:16:35 +02:00
Samy Pessé 34d36c6de1 Fix next-on-pages configuration for ~gitbook/static (#2482) 2024-09-24 17:25:58 +02:00
Brett Jephson 042b850b5d RND-2147: automatically scroll TOC (#2478) 2024-09-24 16:24:55 +01:00
Samy Pessé 73892f4fc2 Fix release preview CI workflow (#2481) 2024-09-24 13:13:45 +02:00
Samy Pessé 636b868bf1 Experimental new cache backend powered by DO (#2479)
Co-authored-by: Johan Preynat <johan.preynat@gmail.com>
2024-09-24 12:55:49 +02:00
Addison b144368e3f Update Dutch "Ask or Search" terminology to fit on one line (#2480) 2024-09-24 16:30:30 +09:00
Scott Cazan 683b4193d1 Don't use customized site title for space in search selector (#2367) 2024-09-23 12:43:03 +02:00
Samy Pessé f4c953665a Optimize layout shift while transitioning between pages with full width blocks (#2477) 2024-09-23 06:19:49 +02:00
Samy Pessé 0f990c772a Show definition title when visible in cards (#2476) 2024-09-22 19:04:23 +02:00
Samy Pessé 0bf985af61 Don't show hidden pages in the empty state of a page (#2475) 2024-09-19 15:17:13 +02:00
Samy Pessé 42473611d3 Add required query parameters to the code sample (#2474) 2024-09-19 14:31:22 +02:00
Samy Pessé 2c437f7d94 Fix linking to tabs itself (#2473) 2024-09-19 10:52:34 +02:00
vibhanshub aa8c49eb0c Display pattern if available in OpenAPI params (#2461)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-09-19 09:59:10 +02:00
Samy Pessé b32e40c72a Persist state of tabs and dynamically sync them based on title (#2472) 2024-09-19 09:40:54 +02:00
Samy Pessé 2ff7ed1b52 Hide button on mobile to open toc if disabled at the page level (#2471) 2024-09-18 19:49:02 +02:00
Samy Pessé bfbed1a69b Use translation for "Sponsored via GitBook" (#2469) 2024-09-17 13:41:53 +02:00
Samy Pessé ef9d012be9 Fix generation of icons package and add task "clean" (#2470) 2024-09-17 13:41:18 +02:00
Nichlas f12a2155c0 Added the Norwegian language (#2467)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-09-17 12:15:21 +02:00
Taran Vohra 43f696c4a8 use samesite none for tracking cookie (#2299) 2024-09-17 11:57:28 +05:30
Samy Pessé 4c19014dcd Prevent robots indexation for pages where it's disabled (#2464) 2024-09-13 13:52:15 +02:00
Samy Pessé 0e7f9d6778 Fix TS error when adding support for a locale not yet supported by API (#2465) 2024-09-13 10:30:20 +02:00
Rens Reinders 00a0bf3c1b Add Dutch Locale support (#2450) 2024-09-13 10:12:44 +02:00
Brett Jephson e9149036f6 RND-3387: synchronize response tabs (#2457)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-09-12 11:01:29 +01:00
Addison b6a9967959 Update search/ask wording for Japanese localization to fit UI better (#2462) 2024-09-12 09:58:58 +02:00
Addison 8bbc3caf3e Update Readme & Contributing Guide (#2459) 2024-09-09 15:28:19 +02:00
Samy Pessé 237b703a6c Fix crash in OpenAPI block because of undefined examples (#2458) 2024-09-06 16:55:04 +02:00
vibhanshub cf3045a718 Add Python support in code samples (#2453) 2024-09-06 14:29:50 +01:00
vibhanshub a679e7283f Render mandatory headers in the code sample (#2445) 2024-09-06 13:24:52 +01:00
Samy Pessé 13c75341e2 Use ellipsis and fix icon color for more links in the header on small screen (#2455) 2024-09-06 11:57:42 +02:00
Marc Laventure 4cbcc5bb20 fix: rollback scalar (#2454)
Co-authored-by: Brett Jephson <brett@gitbook.io>
2024-09-06 10:32:33 +01:00
Brett Jephson 51955dae3b Response examples with status code tabs (#2448) 2024-09-06 09:35:43 +01:00
Brett Jephson 51fa3ab2c8 Use content-visibility on open api operations (#2451) 2024-09-05 17:21:42 +01:00
Samy Pessé af9e66e87e Only display spaces dropdown in compact header when site is multi-variants (#2447) 2024-09-03 19:49:05 +02:00
Samy Pessé 8878dc7f80 Accept special ip headers from gitbook backend (#2446) 2024-09-03 16:47:51 +02:00
Scott Cazan 4336bc20e0 Filter out short searches less then 2 chars, bigger debounce (#2440) 2024-09-03 14:49:35 +02:00
Samy Pessé 4a7613a521 Fix community ads fetching (#2444) 2024-09-03 13:54:31 +02:00
Samy Pessé 689f55340f Fix inconsistent click area in table because of scroll indicator (#2443) 2024-09-02 14:58:56 +02:00
Taran Vohra 160ae3d49d Add spaces dropdown to CompactHeader component (#2439) 2024-08-28 10:08:25 +05:30
Marc Laventure c079c3c6d9 chore: bump scalar package (#2421)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-08-21 08:27:22 +02:00
Samy Pessé cb782a7557 Fix running GitBook locally and fix community ad rendering (#2437) 2024-08-20 19:37:33 +02:00
Abhishek 502d8fc087 Fix typo in README.md (#2430) 2024-08-20 14:55:46 +02:00
Brett Jephson 6295881138 RND-3373: update the shadow for dark mode in multi-space search (#2436) 2024-08-20 13:54:28 +01:00
Brett Jephson 17f71ba680 RND-3113: expand when expandable anchor (#2435) 2024-08-20 11:04:00 +01:00
Brett Jephson 5fe7adb87b RND-3532: drop down menu for hidden links at small screen size (#2434) 2024-08-19 11:51:48 +01:00
spastorelli 41684d091b Revert "Automatically scroll to the active page in table of contents (#2425) (#2429) 2024-08-15 16:52:27 +02:00
spastorelli 9f6c3347cb Automatically scroll to the active page in table of contents (#2425) 2024-08-15 11:42:48 +02:00
Samy Pessé aa321989c4 Use a <div> instead of <h1> in the top header (#2428) 2024-08-14 10:02:42 +02:00
Samy Pessé 0f1565cb4b Fix mermaid / contentkit webframe not updating properly (#2427) 2024-08-13 15:26:11 +02:00
Samy Pessé 3996110ebd Optimize and resize images in community ads (#2426) 2024-08-09 15:41:35 +02:00
Samy Pessé 3422ad4c84 Iteration on community ads (#2422) 2024-08-09 12:31:36 +02:00
spastorelli 3c07e65b9a Fix paragraph top margin in quote blocks (#2423) 2024-08-09 10:58:20 +02:00
Steven H 287df91661 Add an nvmrc file (#2405) 2024-08-07 15:32:08 +01:00
Samy Pessé 776bc3135a Fix running repository without access to Pro token for @gitbook/icons (#2417)
Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com>
2024-08-02 18:40:44 +02:00
Samy Pessé 4638958c9f Fix preview of customization (#2418) 2024-08-02 18:24:41 +02:00
Samy Pessé 1f24fe46c4 Add support for page icons and icons style customization (#2416) 2024-08-02 17:28:32 +02:00
Valentino Hudhra ccbcab8822 Control tracking of page views with env/header variables (#2380) 2024-08-02 16:10:28 +02:00
Marc Laventure 094e9cdac2 chore: bump scalar (#2410)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-08-02 10:24:39 +02:00
Samy Pessé 5c35f36b3a Prepare @gitbook/icons package and use it as a replacement of geist/icons (#2404) 2024-08-02 09:59:44 +02:00
Steven H d7ec2b57a4 Bump getPublishedContentByUrl cache to fix a recent issue with the GitBook API (#2414) 2024-08-01 06:54:09 +01:00
Steven H 373a1e0312 Bump URL resolution caches after a recent hotfix. (#2412) 2024-07-29 14:22:20 +01:00
Taran Vohra e9fe3975bb Pass shareKey as context while fetching space (#2409) 2024-07-26 16:14:20 +05:30
Samy Pessé 32a73b55d4 Version Packages (#2403)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-25 15:12:18 +02:00
Samy Pessé 0426312d6c Reset to first tab when tabs change in an InteractiveSection (#2407) 2024-07-25 15:09:21 +02:00
Samy Pessé ede2335fb3 Fix x-codeSamples: false not working at the single operation level (#2408) 2024-07-25 14:59:30 +02:00
Amrit Kahlon 709f1a1ccb Update Scalar Api Client to new version (#2406)
Co-authored-by: marclave <marc@scalar.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-07-25 10:25:50 +02:00
Samy Pessé 24b785cbfb Update shiki to fix diff code highlighting (#2338) 2024-07-25 09:33:42 +02:00
Samy Pessé acc3f2f418 Fix Scalar proxy failing on Cloudflare (#2402) 2024-07-24 11:41:35 +02:00
Scott Cazan d4a653d6a9 Use SpaceURL in getURL function to for cross-space searches (#2399) 2024-07-23 12:07:32 +02:00
Samy Pessé 7e74d25447 Version Packages (#2397)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-22 16:26:53 +02:00
Samy Pessé 3445db41ce Fix packages not including all files (#2396) 2024-07-22 16:20:06 +02:00
254 changed files with 6656 additions and 3072 deletions
+21 -7
View File
@@ -1,6 +1,8 @@
# Welcome to GitBook's contributing guide! # Welcome to GitBook's contributing guide!
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute. > _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
## Types of Contributions ## Types of Contributions
@@ -45,13 +47,25 @@ Any contribution you make can be made to the code located in this repository. In
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
##### GitHub Codespaces:
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
#### 2. Create a working branch and start with your changes #### 2. Create a working branch and start with your changes
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes! After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
#### 3. Install dependencies and run the project locally
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
You can easily do this by running the command `nvm use`.
To start your local version of GitBook, run the command `bun dev`.
#### 4. Preview your changes
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
### Commit your update ### Commit your update
@@ -71,4 +85,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
### Your PR is merged ### Your PR is merged
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository. Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
+3 -1
View File
@@ -7,7 +7,7 @@ runs:
- name: 🏗 Prepare Playwright env - name: 🏗 Prepare Playwright env
shell: bash shell: bash
run: | run: |
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version') PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
# Cache browser binaries, cache key is based on Playwright version and OS # Cache browser binaries, cache key is based on Playwright version and OS
@@ -24,6 +24,7 @@ runs:
- name: 🏗 Install Playwright browser binaries & OS dependencies - name: 🏗 Install Playwright browser binaries & OS dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash shell: bash
working-directory: packages/gitbook
run: | run: |
bun x playwright install --with-deps chromium bun x playwright install --with-deps chromium
@@ -31,5 +32,6 @@ runs:
- name: 🏗 Install Playwright OS dependencies - name: 🏗 Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true' if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash shell: bash
working-directory: packages/gitbook
run: | run: |
bun x playwright install-deps bun x playwright install-deps
+69 -12
View File
@@ -4,7 +4,8 @@ on:
push: push:
branches: branches:
- main - main
env:
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
jobs: jobs:
deploy: deploy:
name: Deploy to Cloudflare Pages name: Deploy to Cloudflare Pages
@@ -12,8 +13,12 @@ jobs:
permissions: permissions:
contents: read contents: read
deployments: write deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs: outputs:
deployment_url: ${{ steps.cloudflare.outputs.url }} deployment_url: ${{ steps.deploy.outputs.url }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -51,27 +56,55 @@ jobs:
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 }}
- id: cloudflare - id: deploy
name: Publish to Cloudflare Pages name: Deploy to Cloudflare
uses: cloudflare/pages-action@v1 uses: cloudflare/wrangler-action@v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: ./packages/gitbook/.vercel/output/static
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '3.82.0'
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs - name: Outputs
run: | run: |
echo "ID: ${{ steps.cloudflare.outputs.id }}" echo "URL: ${{ steps.deploy.outputs.url }}"
echo "URL: ${{ steps.cloudflare.outputs.url }}" echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
- name: Archive build output - name: Archive build output
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-output name: build-output
path: .vercel/ path: .vercel/
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
- name: Update Deployment Status to Success
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }}
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
- name: Find GitHub Comment
uses: peter-evans/find-comment@v3
id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: GitBook Preview
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
**GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.url }}](${{ steps.deploy.outputs.url }})
PR: [${{ steps.deploy.outputs.alias }}](${{ steps.deploy.outputs.alias }})
edit-mode: replace
visual-testing: visual-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing name: Visual Testing
@@ -92,6 +125,12 @@ jobs:
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
if: ${{ !cancelled() }}
with:
name: playwright-test-results
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
@@ -157,6 +196,24 @@ jobs:
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit - run: bun unit
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build (Open Source)
env:
NPMRC_FONT_AWESOME_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
typecheck: typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Typecheck name: Typecheck
+23
View File
@@ -35,3 +35,26 @@ jobs:
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
# For now it releases the cache-do to both preview and production
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
name: Release Preview
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Release preview packages
run: bun run release:preview
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+2
View File
@@ -20,3 +20,5 @@ yarn-error.log*
# Vercel # Vercel
.vercel .vercel
# Env files
.env.local
+1
View File
@@ -0,0 +1 @@
v20.6
+4
View File
@@ -4,3 +4,7 @@
# Generated # Generated
packages/emoji-codepoints/index.ts packages/emoji-codepoints/index.ts
packages/gitbook/public/~gitbook/static/ packages/gitbook/public/~gitbook/static/
packages/icons/src/data/*.json
# Build files
dist/
+38 -9
View File
@@ -5,7 +5,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></a> <a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a> <a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a> <a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a> <a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
@@ -14,7 +14,7 @@
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p> <p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p> <p align="center">This repository contains the open source code used to render GitBook's published content.</p>
<p align="center"> <p align="center">
<img alt="GitBook Open Published Site" src="./assets/published-site.png"> <img alt="GitBook Open Published Site" src="./assets/published-site.png">
@@ -46,19 +46,28 @@ To run a local version of this project, please follow these simple steps.
git clone https://github.com/gitbookIO/gitbook.git git clone https://github.com/gitbookIO/gitbook.git
``` ```
2. Install the project's dependencies through Bun. 2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
3. Install the project's dependencies through Bun.
``` ```
bun install bun install
``` ```
3. Start your local development server. 4. Start your local development server.
``` ```
bun dev bun dev
``` ```
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com). 5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
- http://localhost:3000/docs.gitbook.com
- http://localhost:3000/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
### Other development commands ### Other development commands
@@ -71,7 +80,7 @@ All pull-requests will be tested against both visual and performances testing to
## 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/.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.
### Types of contributions ### Types of contributions
@@ -79,7 +88,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
#### Translations #### Translations
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI. The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
#### Bugs #### Bugs
@@ -90,7 +99,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
> [!WARNING] > [!WARNING]
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need. > While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
> >
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._ > _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
> >
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project. > Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
> >
@@ -106,10 +115,30 @@ On the con side, you become responsible for the reliability of your published si
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE). Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
If you plan to distribute the code, you must the source code public to comply with GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing). If you plan to distribute the code, you must make the source code public to comply with the GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
See `LICENSE` for more information. See `LICENSE` for more information.
## Badges
<p align="left">
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=F4E28D"></a>
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=FDA599"></a>
</p>
```md
[![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://gitbook.com/)
```
```html
<a href="https://gitbook.com">
<img
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
/>
</a>
```
## Acknowledgements ## Acknowledgements
GitBook wouldn't be possible without these projects: GitBook wouldn't be possible without these projects:
BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
[install.scopes]
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
+15 -8
View File
@@ -1,27 +1,34 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"private": true, "devDependencies": {
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.1.2"
},
"packageManager": "bun@1.1.18", "packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
},
"private": true,
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"build": "turbo run build", "build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare", "build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint", "lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"format": "prettier ./ --ignore-unknown --write", "format": "prettier ./ --ignore-unknown --write",
"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",
"changeset": "changeset", "changeset": "changeset",
"release": "turbo run build && changeset publish" "release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
}, },
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ]
"devDependencies": {
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.0.9"
}
} }
+3
View File
@@ -0,0 +1,3 @@
.wrangler
worker-configuration.d.ts
dist/
+12
View File
@@ -0,0 +1,12 @@
# @gitbook/cache-do
## 0.1.0
### Minor Changes
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
### Patch Changes
- 56f5fa1: Enable Workers observability with a sampling of 0.1
+22
View File
@@ -0,0 +1,22 @@
# `@gitbook/cache-do`
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
### Performances
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
### Geo-distribution
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
### Concepts
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
**Location**: cache is distributed over 7 unique locations, one for each continent.
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@gitbook/cache-do",
"type": "module",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
},
"./api": {
"types": "./dist/api.d.ts",
"development": "./src/api.ts",
"default": "./dist/api.js"
}
},
"version": "0.1.0",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "3.82.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"release": "wrangler deploy",
"release:preview": "wrangler deploy && wrangler deploy --env preview"
},
"files": [
"dist",
"src",
"bin",
"data",
"README.md",
"CHANGELOG.md"
]
}
+299
View File
@@ -0,0 +1,299 @@
import { encode, decode } from '@msgpack/msgpack';
import { DurableObject } from 'cloudflare:workers';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
}
/**
* Value stored in a chunked binary msgpack format.
* Stored under the key `prop.${key}.${index}`.
*/
interface CacheObjectProp<Value = unknown> {
value: Value;
expiresAt: number;
}
/**
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
*/
interface CacheObjectExp {
/** Key of the property */
k: string;
/** Number of chunks */
c: number;
}
/**
* Durable Object class being deployed as a distributed cache.
*/
export class CacheObject extends DurableObject {
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
/**
* Open a descriptor to access the cache object.
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
* One session is opened per request on the client side and used to perform multiple operations.
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
*/
public open(): CacheObjectDescriptor {
return {
get: async <Value = unknown>(key: string) => {
return this.get<Value>(key);
},
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
await this.set(key, value, expiresAt);
},
};
}
/**
* Get the value of a property.
*/
public async get<Value = unknown>(key: string) {
return this.logOperation({ operation: 'get', key }, async (setLog) => {
// Try the memory state first.
const memoryEntry = this.lru.get(key);
if (memoryEntry) {
setLog({ memory: true });
setLog({ memoryMatch: !!memoryEntry.match });
if (!memoryEntry.match) {
return;
}
const isExpired = memoryEntry.match.expiresAt < Date.now();
setLog({ memoryExpired: isExpired });
if (!isExpired) {
return memoryEntry.match.value as Value;
}
}
return await this.getFromStorage<Value>(key);
});
}
/**
* Get the value of a property from the DO storage.
*/
public async getFromStorage<Value = unknown>(key: string) {
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
if (entry && entry.value.expiresAt > Date.now()) {
// Found
this.lru.set(key, { match: entry.value });
return entry.value.value;
}
}
// Not found
this.lru.set(key, { match: undefined });
});
}
/**
* Set a value in the cache object.
*/
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return this.logOperation({ operation: 'set', key }, async (setLog) => {
const prop: CacheObjectProp<Value> = {
value,
expiresAt,
};
this.lru.set(key, { match: prop });
await this.ctx.storage.transaction(async (tx) => {
const entries = encodeChunks(key, prop);
const chunks = Object.keys(entries).length;
setLog({ chunks });
const clockValue: CacheObjectExp = {
k: key,
c: chunks,
};
await tx.put(getGCClockKey(key, expiresAt), clockValue);
await tx.put(entries);
const currentAlarm = await tx.getAlarm();
if (!currentAlarm) {
// Set an alarm to garbage collect all entries that have expired in 12h.
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
});
});
}
/**
* Purge all keys in the cache object.
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
let result = new Set<string>();
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during purge, resetting the cache object', error);
}
await this.reset();
return Array.from(result);
});
}
/**
* Alarm to garbage collect all entries that have expired.
*/
async alarm() {
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
try {
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
const toDeleteSet = new Set<string>();
for (const [key, exp] of entries) {
const timestamp = parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
}
}
}
// Delete the keys by batch of 128.
const toDelete = Array.from(toDeleteSet);
setLog({ toDelete: toDelete.length });
for (let i = 0; i < toDelete.length; i += 128) {
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
}
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during alarm, reset the cache object', error);
await this.reset();
}
});
}
/**
* Reset the cache object.
*/
async reset() {
return this.logOperation({ operation: 'reset' }, async () => {
this.lru.clear();
await this.ctx.storage.deleteAll();
});
}
/**
* Time and log an operation.
*/
async logOperation<T>(
log: Record<string, unknown>,
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>,
): Promise<T> {
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
let update: Record<string, unknown> = {};
const start = performance.now();
try {
return await fn((arg) => {
Object.assign(update, arg);
});
} finally {
const duration = performance.now() - start;
console.log({ ...log, ...update, objectId, duration });
}
}
}
function getStoragePropKey(key: string): string {
return `prop.${key}.`;
}
function getStoragePropChunkKey(key: string, index: number): string {
return `${getStoragePropKey(key)}${index}`;
}
function getGCClockRootKey(timestamp: number): string {
return `exp.${timestamp}.`;
}
function getGCClockKey(key: string, expiresAt: number): string {
return `${getGCClockRootKey(expiresAt)}${key}`;
}
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
const buf = encode(value);
const entries: Record<string, Uint8Array> = {};
const chunks = chunkUint8Array(buf, 128 * 1024);
for (let index = 0; index < chunks.length; index++) {
entries[getStoragePropChunkKey(key, index)] = chunks[index];
}
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = parseInt(key.split('.').pop()!);
return [index, value] as const;
})
.sort(([a], [b]) => a - b)
.map(([, value]) => value);
if (chunks.length === 0) {
return;
}
const buf = mergeUint8Array(chunks);
return { value: decode(buf) as T, size: buf.length };
}
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
const chunks: Uint8Array[] = [];
for (let i = 0; i < input.length; i += chunkSize) {
chunks.push(input.slice(i, i + chunkSize));
}
return chunks;
}
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
+97
View File
@@ -0,0 +1,97 @@
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
export type CacheLocationId = ContinentCode;
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
/**
* Location hint for the CacheObject durable object.
*/
const doLocationHints: {
[key in CacheLocationId]: DurableObjectLocationHint;
} = {
AF: 'afr',
AS: 'apac',
NA: 'wnam',
SA: 'sam',
AN: 'oc',
EU: 'weur',
OC: 'oc',
};
/**
* Client to access a cache tag.
*/
export class CacheObjectStub {
private stub: DurableObjectStub<CacheObject>;
constructor(
/** Binding to the CacheObject durable object */
private doNamespace: DurableObjectNamespace<CacheObject>,
/** ID of the location to target */
private locationId: CacheLocationId,
/** Name of the tag */
private tag: string,
) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
}
/**
* Open a descriptor to the cache object.
* It can be used to perform multiple operations in a single RPC session.
* Ex:
* ```ts
* using desc = cache.open();
* await desc.set('key', 'value', Date.now() + 1000);
* await desc.get('key');
* ```
*/
async open() {
return await this.stub.open();
}
/**
* Get a value from the cache.
*/
async get<Value = unknown>(key: string) {
return (await this.stub.get(key)) as Value | undefined;
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return await this.stub.set(key, value, expiresAt);
}
/**
* Purge all keys in the cache tag.
*/
async purge() {
const keys = new Set<string>();
await Promise.all(
allLocations.map(async (locationId) => {
const groupId = getCacheObjectIdName(locationId, this.tag);
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
const locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
}),
);
return keys;
}
}
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
return `${locationId}:${tag}`;
}
+1
View File
@@ -0,0 +1 @@
export * from './CacheObjectStub';
+9
View File
@@ -0,0 +1,9 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
export * from './CacheObject';
export default class Worker extends WorkerEntrypoint {
fetch() {
return new Response('Hello, world!');
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"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,
"incremental": true,
"types": ["./.wrangler/types/runtime.d.ts"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
+28
View File
@@ -0,0 +1,28 @@
main = "./src/index.ts"
name = "gitbook-open-cache"
compatibility_date = "2024-09-02"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[observability]
enabled = true
head_sampling_rate = 0.01
[env.preview]
name = "gitbook-open-cache-preview"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[env.preview.observability]
enabled = true
head_sampling_rate = 1
+1 -1
View File
@@ -1 +1 @@
index.ts dist/
+4 -2
View File
@@ -1,4 +1,5 @@
import fs from 'fs'; import fs from 'node:fs';
import path from 'node:path';
import emojisRaws from 'emoji-assets/emoji.json'; import emojisRaws from 'emoji-assets/emoji.json';
interface EmojiData { interface EmojiData {
@@ -20,7 +21,8 @@ Object.entries(emojis).forEach(([key, value]) => {
} }
}); });
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
'index.ts', path.resolve(__dirname, 'dist/index.ts'),
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`, `export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
); );
+3 -2
View File
@@ -3,12 +3,13 @@
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.0", "version": "0.2.0",
"private": true, "private": true,
"exports": "./index.ts", "exports": "./dist/index.ts",
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"emoji-assets": "^8.0.0" "emoji-assets": "^8.0.0"
}, },
"scripts": { "scripts": {
"postinstall": "bun ./build.ts" "generate": "bun ./build.ts",
"clean": "rm -rf ./dist"
} }
} }
+3
View File
@@ -25,3 +25,6 @@
### Sentry ### ### Sentry ###
# SENTRY_DSN=xxx # SENTRY_DSN=xxx
### Silent logs
# SILENT=true
+2 -1
View File
@@ -32,4 +32,5 @@ screenshots/
/playwright/.cache/ /playwright/.cache/
# Generated public files # Generated public files
/public/~gitbook/static/ /public/~gitbook/static/*
!/public/~gitbook/static/images
+118
View File
@@ -1,5 +1,123 @@
# gitbook # gitbook
## 0.4.0
### Minor Changes
- e09f747: Revalidate change request cached content when pressing refresh button
- 2fa0851: Add navigation tabs for sections
- a4b63b8: Support resolution of new site URLs with sections
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
- e9b31a5: Unify section tab styles with page item styles
- f12a215: Add support for Norwegian language
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
- 1f24fe4: Add support for page icons
- cda08a9: Add support for searching results in a sections site
- b32e40c: Persist state of tabs and dynamically sync them based on title
- 15d2ee3: Show the caption for file blocks
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
- 07ea45b: Remove deprecated synced block from GitBook Open
- c3675fd: Added support for new Reusable Content block.
- 1f24fe4: Add support for icons style customization for sites
- 4c19014: Prevent search indexation for pages where it's configured as disabled
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
- 2c437f7: Fix linking to a tab itself
### Patch Changes
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
- a7066cc: Fix scroll position when navigating pages on mobile
- c754fc9: Add automatic color contrast in site header, restyle search button
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
- 6295881: Change dark mode shadow for multi-space search toolbar
- f89b31c: Upgrade the scalar api client package
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
- f885e88: Improve consistency of change request preview by removing cache-control on response
- 16e6171: Improve performances of loading pages with embeds by caching them
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
- 042b850: Automatically scroll to active item in TOC
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
- fe9e6c1: Update ogimage with new design
- 17f71ba: Use url hash to open Expandable and scroll to anchor
- 3c07e65: Fix margin for paragraphs in quote blocks
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
- f16560c: Include offset in calculations of whether scrollable element is in view
- 689f553: Fix inconsistent click area in table because of scroll indicator
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
- e914903: Synchronize response and response example tabs
- 0f990c7: Show definition title when visible in cards
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
- 3996110: Optimize images rendered in community ads
- 133c3e7: Update design of Checkbox to be more consistent and readable
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
- cb782a7: Fix "ip" being passed to BSA for community ads
- a7af3ca: Improving the look and feel of new section tabs
- 0bf985a: Don't show hidden pages in the empty state of a page
- d6c28a0: Update header styling of sections, variant selector, and button links
- Change position of variant selector depending on context (next to logo or in table of contents)
- Update section tab styling and animation
- Make header buttons smaller with a new `medium` button size
- Updated dependencies [51fa3ab]
- Updated dependencies [9b8d519]
- Updated dependencies [cf3045a]
- Updated dependencies [f89b31c]
- Updated dependencies [d0f4860]
- Updated dependencies [ef9d012]
- Updated dependencies [094e9cd]
- Updated dependencies [636b868]
- Updated dependencies [56f5fa1]
- Updated dependencies [5c35f36]
- Updated dependencies [4247361]
- Updated dependencies [aa8c49e]
- Updated dependencies [e914903]
- Updated dependencies [4cbcc5b]
- Updated dependencies [0f1565c]
- Updated dependencies [237b703]
- Updated dependencies [51955da]
- Updated dependencies [a679e72]
- Updated dependencies [c079c3c]
- Updated dependencies [5c35f36]
- Updated dependencies [776bc31]
- @gitbook/react-openapi@0.7.0
- @gitbook/cache-do@0.1.0
- @gitbook/icons@0.1.0
- @gitbook/react-contentkit@0.5.1
- @gitbook/react-math@0.6.0
## 0.3.0
### Minor Changes
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
### Patch Changes
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
- Updated dependencies [709f1a1]
- Updated dependencies [ede2335]
- Updated dependencies [0426312]
- @gitbook/react-openapi@0.6.0
## 0.2.2
### Patch Changes
- Updated dependencies [3445db4]
- @gitbook/react-contentkit@0.5.0
- @gitbook/react-openapi@0.5.0
- @gitbook/react-math@0.5.0
## 0.2.1 ## 0.2.1
### Patch Changes ### Patch Changes
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"exclude": ["/~gitbook/static/*"]
}
+7
View File
@@ -0,0 +1,7 @@
import type { CacheObject } from '@gitbook/cache-do';
declare global {
interface CloudflareEnv {
CACHE?: DurableObjectNamespace<CacheObject>;
}
}
+165 -25
View File
@@ -1,21 +1,24 @@
import { argosScreenshot } from '@argos-ci/playwright'; import { argosScreenshot } from '@argos-ci/playwright';
import { import {
CustomizationHeaderPreset, CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale, CustomizationLocale,
CustomizationSettings, SiteCustomizationSettings,
} from '@gitbook/api'; } from '@gitbook/api';
import { test, expect, Page } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import rison from 'rison'; import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { getContentTestURL } from '../tests/utils'; import { getContentTestURL } from '../tests/utils';
interface Test { interface Test {
name: string; name: string;
url: string; url: string; // URL to visit for testing
run?: (page: Page) => Promise<unknown>; run?: (page: Page) => Promise<unknown>; // The test to run
fullPage?: boolean; fullPage?: boolean; // Whether the test should be fullscreened during testing
screenshot?: false; screenshot?: false; // Should a screenshot be stored
only?: boolean; // Only run this test
} }
interface TestsCase { interface TestsCase {
@@ -102,7 +105,32 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'RFC variant', name: 'RFC variant',
url: 'v/rfcs', url: 'rfcs',
},
{
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="space-dropdown-button"] + div',
);
// the customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
name: 'Multi-Variants',
}),
).toBeVisible();
// the NON-customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
name: 'RFCs',
}),
).toBeVisible();
},
}, },
], ],
}, },
@@ -129,13 +157,13 @@ const testCases: TestsCase[] = [
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL( await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/v/2.0/reference/api-reference/pets?fallback=true', 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/2.0/reference/api-reference/pets?fallback=true',
); );
}, },
}, },
{ {
name: 'Keep navigation path/route when switching variant (Share link)', name: 'Keep navigation path/route when switching variant (Share link)',
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets', url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false, screenshot: false,
run: async (page) => { run: async (page) => {
const spaceDrowpdown = await page.waitForSelector( const spaceDrowpdown = await page.waitForSelector(
@@ -152,7 +180,7 @@ const testCases: TestsCase[] = [
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL( await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/v/2.0/reference/api-reference/pets?fallback=true', 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets?fallback=true',
); );
}, },
}, },
@@ -187,7 +215,7 @@ const testCases: TestsCase[] = [
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL( await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/v/2.0/reference/api-reference/pets?fallback=true', 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/2.0/reference/api-reference/pets?fallback=true',
); );
}, },
}, },
@@ -230,7 +258,7 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'Versioning', name: 'Versioning',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [ tests: [
{ {
name: 'Revision', name: 'Revision',
@@ -241,7 +269,7 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'PDF', name: 'PDF',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [ tests: [
{ {
name: 'PDF', name: 'PDF',
@@ -251,7 +279,7 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'Content tests', name: 'Content tests',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [ tests: [
{ {
name: 'Text', name: 'Text',
@@ -344,11 +372,21 @@ const testCases: TestsCase[] = [
name: 'Math', name: 'Math',
url: 'blocks/math', url: 'blocks/math',
}, },
{
name: 'Files',
url: 'blocks/files',
fullPage: true,
},
{ {
name: 'Embeds', name: 'Embeds',
url: 'blocks/embeds', url: 'blocks/embeds',
fullPage: true, fullPage: true,
}, },
{
name: 'Page links',
url: 'blocks/page-links',
fullPage: true,
},
{ {
name: 'Annotations', name: 'Annotations',
url: 'blocks/annotations', url: 'blocks/annotations',
@@ -357,11 +395,15 @@ const testCases: TestsCase[] = [
await page.click('[data-testid="annotation-button"]'); await page.click('[data-testid="annotation-button"]');
}, },
}, },
{
name: 'Stepper',
url: 'blocks/stepper',
},
], ],
}, },
{ {
name: 'Page options', name: 'Page options',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [ tests: [
{ {
name: 'Hidden', name: 'Hidden',
@@ -383,11 +425,16 @@ const testCases: TestsCase[] = [
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{
name: 'With icon',
url: 'page-options/page-with-icon',
run: waitForCookiesDialog,
},
], ],
}, },
{ {
name: 'Customization', name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [ tests: [
{ {
name: 'Without header', name: 'Without header',
@@ -399,16 +446,58 @@ const testCases: TestsCase[] = [
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{
name: 'With duotone icons',
url:
'page-options/page-with-icon' +
getCustomizationURL({
styling: {
icons: CustomizationIconsStyle.Duotone,
},
}),
run: waitForCookiesDialog,
},
{
name: 'With header buttons',
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.Default,
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
}),
run: waitForCookiesDialog,
},
],
},
{
name: 'Ads',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
], ],
}, },
{ {
name: 'Share links', name: 'Share links',
baseUrl: 'https://gitbook.gitbook.io/test-share-links/', baseUrl: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
tests: [ tests: [
{ {
name: 'Valid link', name: 'Valid link',
url: 'Fc6mMII9FKgnwm7qqynx/', url: 'thDznyWXCeEoT55WB7HC/',
run: waitForCookiesDialog,
}, },
{ {
name: 'Invalid link', name: 'Invalid link',
@@ -495,7 +584,7 @@ const testCases: TestsCase[] = [
expiresIn: '24h', expiresIn: '24h',
}, },
); );
return `v/spacea?jwt_token=${token}`; return `spacea?jwt_token=${token}`;
})(), })(),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
@@ -512,7 +601,7 @@ const testCases: TestsCase[] = [
expiresIn: '24h', expiresIn: '24h',
}, },
); );
return `v/spaceb?jwt_token=${token}`; return `spaceb?jwt_token=${token}`;
})(), })(),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
@@ -529,7 +618,7 @@ const testCases: TestsCase[] = [
expiresIn: '24h', expiresIn: '24h',
}, },
); );
return `v/spacec?jwt_token=${token}`; return `spacec?jwt_token=${token}`;
})(), })(),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
@@ -611,13 +700,12 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'Languages', name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: allLocales.map((locale) => ({ tests: allLocales.map((locale) => ({
name: locale, name: locale,
url: getCustomizationURL({ url: getCustomizationURL({
internationalization: { internationalization: {
locale, locale,
inherit: false,
}, },
}), }),
run: async (page) => { run: async (page) => {
@@ -626,12 +714,64 @@ const testCases: TestsCase[] = [
}, },
})), })),
}, },
{
name: 'SEO',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: `Index by default`,
url: '?x-gitbook-search-indexation=true',
screenshot: false,
run: async (page) => {
const metaRobots = page.locator('meta[name="robots"]');
await expect(metaRobots).toHaveAttribute('content', 'index, follow');
},
},
{
name: `Don't index noIndex`,
url: 'page-options/page-no-index?x-gitbook-search-indexation=true',
screenshot: false,
run: async (page) => {
const metaRobots = page.locator('meta[name="robots"]');
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
},
},
{
name: `Don't index descendant of noIndex`,
url: 'page-options/page-no-index/descendant-of-page-no-index?x-gitbook-search-indexation=true',
screenshot: false,
run: async (page) => {
const metaRobots = page.locator('meta[name="robots"]');
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
},
},
{
name: `Don't index noRobotsIndex`,
url: 'page-options/page-no-robots-index?x-gitbook-search-indexation=true',
screenshot: false,
run: async (page) => {
const metaRobots = page.locator('meta[name="robots"]');
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
},
},
{
name: `Don't index descendant of noRobotsIndex`,
url: 'page-options/page-no-robots-index/descendant-of-page-no-robots-index?x-gitbook-search-indexation=true',
screenshot: false,
run: async (page) => {
const metaRobots = page.locator('meta[name="robots"]');
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
},
},
],
},
]; ];
for (const testCase of testCases) { for (const testCase of testCases) {
test.describe(testCase.name, () => { test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) { for (const testEntry of testCase.tests) {
test(testEntry.name, async ({ page, baseURL }) => { const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, baseURL }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl); const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL); const url = getContentTestURL(contentUrl.toString(), baseURL);
await page.goto(url); await page.goto(url);
@@ -658,7 +798,7 @@ for (const testCase of testCases) {
/** /**
* Create a URL with customization settings. * Create a URL with customization settings.
*/ */
function getCustomizationURL(partial: Partial<CustomizationSettings>): string { function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
const encoded = rison.encode_object(partial); const encoded = rison.encode_object(partial);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
+2
View File
@@ -7,6 +7,8 @@ module.exports = withSentryConfig(
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_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
}, },
webpack(config, { dev, webpack }) { webpack(config, { dev, webpack }) {
+18 -11
View File
@@ -1,9 +1,9 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.2.1", "version": "0.4.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "env-cmd --silent -f ../../.env.local next dev",
"build": "next build", "build": "next build",
"build:cloudflare": "next-on-pages", "build:cloudflare": "next-on-pages",
"start": "next start", "start": "next start",
@@ -11,14 +11,18 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"e2e": "playwright test", "e2e": "playwright test",
"unit": "bun test {src,packages}", "unit": "bun test {src,packages}",
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R ../../node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2" "generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
}, },
"dependencies": { "dependencies": {
"@geist-ui/icons": "^1.0.2", "@gitbook/api": "^0.73.0",
"@gitbook/api": "^0.56.0", "@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1", "@sentry/nextjs": "^7.94.1",
@@ -37,13 +41,14 @@
"katex": "^0.16.9", "katex": "^0.16.9",
"mathjax": "^3.2.2", "mathjax": "^3.2.2",
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
"next": "^14.1.3", "next": "14.2.15",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nuqs": "^1.17.4", "nuqs": "^1.17.4",
"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", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
@@ -55,7 +60,7 @@
"remark-rehype": "^11.1.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.2.0", "shiki": "^1.22.0",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2", "tailwind-shades": "^1.1.2",
"unified": "^11.0.4", "unified": "^11.0.4",
@@ -64,8 +69,8 @@
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^2.0.0", "@argos-ci/playwright": "^2.0.0",
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.1",
"@cloudflare/next-on-pages": "^1.11.3", "@cloudflare/next-on-pages": "^1.13.5",
"@cloudflare/workers-types": "^4.20231218.0", "@cloudflare/workers-types": "^4.20240725.0",
"@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",
@@ -84,6 +89,8 @@
"postcss": "^8", "postcss": "^8",
"psi": "^4.1.0", "psi": "^4.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.5.3" "typescript": "^5.5.3",
"env-cmd": "^10.1.0",
"ts-essentials": "^10.0.1"
} }
} }
+3
View File
@@ -0,0 +1,3 @@
# GitBook immutable static assets
/~gitbook/static/*
cache-control: public,max-age=31536000,immutable
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
keys: result.keys,
stats: result.stats, stats: result.stats,
}); });
} }
@@ -3,15 +3,15 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react'; import React from 'react';
import { useScrollToHash } from '@/components/hooks'; import { useScrollPage } from '@/components/hooks';
/** /**
* Client component to initialize interactivity for a page. * Client component to initialize interactivity for a page.
*/ */
export function PageClientLayout(props: {}) { export function PageClientLayout(props: {}) {
// We use this hook in the page layout to ensure the elements for the blocks // We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to the hash. // are rendered before we scroll to a hash or to the top of the page
useScrollToHash(); useScrollPage();
useStripFallbackQueryParam(); useStripFallbackQueryParam();
return null; return null;
@@ -19,7 +19,7 @@ export default function PageSkeleton() {
'lg:items-start', 'lg:items-start',
)} )}
> >
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto')}> <div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'page-full-width:mx-0')}>
<SkeletonHeading style={tcls('mb-8')} /> <SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} /> <SkeletonParagraph style={tcls('mb-4')} />
</div> </div>
@@ -1,14 +1,11 @@
import { getSpaceLanguage, t } from '@/intl/server'; import { getSpaceLanguage, t } from '@/intl/server';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api'; import { getSiteLayoutData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentPointer } from '../../fetch';
export default async function NotFound() { export default async function NotFound() {
const pointer = getContentPointer(); const pointer = getSiteContentPointer();
const { customization } = await ('siteId' in pointer const { customization } = await getSiteLayoutData(pointer);
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
@@ -8,6 +8,7 @@ import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links'; import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages'; import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references'; import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils'; import { getContentTitle } from '@/lib/utils';
@@ -28,8 +29,9 @@ export default async function Page(props: {
const { const {
content: contentPointer, content: contentPointer,
contentTarget, contentTarget,
sections,
space, space,
parent, site,
customization, customization,
pages, pages,
page, page,
@@ -64,21 +66,39 @@ export default async function Page(props: {
const withPageFeedback = customization.feedback.enabled; const withPageFeedback = customization.feedback.enabled;
const contentRefContext: ContentRefContext = { const contentRefContext: ContentRefContext = {
siteContext: contentPointer,
space, space,
revisionId: contentTarget.revisionId, revisionId: contentTarget.revisionId,
pages, pages,
page, page,
}; };
const withSections = Boolean(sections && sections.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return ( return (
<> <>
{withFullPageCover && page.cover ? ( {withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} /> <PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
) : null} ) : null}
<div className={tcls('flex', 'flex-row')}> {/* 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 <PageBody
space={space} space={space}
contentPointer={contentPointer} pointer={contentPointer}
contentTarget={contentTarget} contentTarget={contentTarget}
customization={customization} customization={customization}
context={contentRefContext} context={contentRefContext}
@@ -89,19 +109,6 @@ export default async function Page(props: {
withPageFeedback && !page.layout.outline withPageFeedback && !page.layout.outline
} }
/> />
{page.layout.outline ? (
<PageAside
space={space}
site={parent?.object === 'site' ? parent : undefined}
customization={customization}
page={page}
document={document}
withHeaderOffset={withTopHeader}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={contentRefContext}
/>
) : null}
</div> </div>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<PageClientLayout /> <PageClientLayout />
@@ -128,7 +135,7 @@ export async function generateMetadata({
params: PagePathParams; params: PagePathParams;
searchParams: { fallback?: string }; searchParams: { fallback?: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const { space, pages, page, customization, parent } = await getPageDataWithFallback({ const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: params, pagePathParams: params,
searchParams, searchParams,
}); });
@@ -138,7 +145,7 @@ export async function generateMetadata({
} }
return { return {
title: [page.title, getContentTitle(space, customization, parent)] title: [page.title, getContentTitle(space, customization, site ?? null)]
.filter(Boolean) .filter(Boolean)
.join(' | '), .join(' | '),
description: page.description ?? '', description: page.description ?? '',
@@ -151,6 +158,10 @@ export async function generateMetadata({
absoluteHref(`~gitbook/ogimage/${page.id}`, true), absoluteHref(`~gitbook/ogimage/${page.id}`, true),
], ],
}, },
robots:
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
}; };
} }
@@ -13,12 +13,12 @@ import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build'; import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp'; import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { absoluteHref, baseUrl } from '@/lib/links'; import { absoluteHref, baseUrl } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo'; import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils'; import { getContentTitle } from '@/lib/utils';
import { ClientContexts } from './ClientContexts'; import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector'; import { RocketLoaderDetector } from './RocketLoaderDetector';
import { fetchSpaceData } from '../fetch'; import { fetchContentData } from '../fetch';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -35,11 +35,12 @@ export default async function ContentLayout(props: { children: React.ReactNode }
contentTarget, contentTarget,
customization, customization,
pages, pages,
parent, site,
spaces, spaces,
ancestors, ancestors,
scripts, scripts,
} = await fetchSpaceData(); sections,
} = await fetchContentData();
ReactDOM.preconnect(api().endpoint); ReactDOM.preconnect(api().endpoint);
if (assetsDomain) { if (assetsDomain) {
@@ -64,8 +65,9 @@ export default async function ContentLayout(props: { children: React.ReactNode }
<SpaceLayout <SpaceLayout
space={space} space={space}
contentTarget={contentTarget} contentTarget={contentTarget}
parent={parent} site={site}
spaces={spaces} spaces={spaces}
sections={sections}
customization={customization} customization={customization}
pages={pages} pages={pages}
ancestors={ancestors} ancestors={ancestors}
@@ -97,7 +99,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
} }
export async function generateViewport(): Promise<Viewport> { export async function generateViewport(): Promise<Viewport> {
const { customization } = await fetchSpaceData(); const { customization } = await fetchContentData();
return { return {
colorScheme: customization.themes.toggeable colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark ? customization.themes.default === CustomizationThemeMode.Dark
@@ -108,11 +110,11 @@ export async function generateViewport(): Promise<Viewport> {
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const { space, parent, customization } = await fetchSpaceData(); const { space, site, customization } = await fetchContentData();
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return { return {
title: getContentTitle(space, customization, parent), title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`, generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()), metadataBase: new URL(baseUrl()),
icons: { icons: {
@@ -133,7 +135,7 @@ export async function generateMetadata(): Promise<Metadata> {
}, },
], ],
}, },
robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow', robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
}; };
} }
@@ -3,9 +3,8 @@ import { NextRequest } from 'next/server';
import { getCollection, getSite, getSpace } from '@/lib/api'; import { getCollection, getSite, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo'; import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
import { getContentPointer } from '../../fetch';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -13,19 +12,16 @@ 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 = getContentPointer(); const pointer = getSiteContentPointer();
const space = await getSpace(pointer.spaceId); const [site, space] = await Promise.all([
const parent = getSite(pointer.organizationId, pointer.siteId),
'siteId' in pointer getSpace(pointer.spaceId, pointer.siteShareKey),
? await getSite(pointer.organizationId, pointer.siteId) ]);
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const lines = [ const lines = [
`User-agent: *`, `User-agent: *`,
'Disallow: /~gitbook/', 'Disallow: /~gitbook/',
...(shouldIndexSpace({ space, parent }) ...(isSpaceIndexable({ space, site })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`] ? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]), : [`Disallow: /`]),
]; ];
@@ -5,8 +5,8 @@ import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api'; import { getSpaceContentData } from '@/lib/api';
import { absoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages'; import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { getContentPointer } from '../../fetch'; import { isPageIndexable } from '@/lib/seo';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -14,8 +14,10 @@ export const runtime = 'edge';
* Generate a sitemap.xml for the current space. * Generate a sitemap.xml for the current space.
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { pages: rootPages } = await getSpaceContentData(getContentPointer()); const pointer = getSiteContentPointer();
const pages = flattenPages(rootPages, (page) => !page.hidden); const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
const urls = pages.map(({ page, depth }) => { const urls = pages.map(({ page, depth }) => {
// Decay priority with depth // Decay priority with depth
const priority = Math.pow(2, -0.25 * depth); const priority = Math.pow(2, -0.25 * depth);
@@ -68,19 +70,26 @@ type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages( function flattenPages(
rootPags: RevisionPage[], rootPags: RevisionPage[],
filter: (page: RevisionPageDocument) => boolean, filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): FlatPageEntry[] { ): FlatPageEntry[] {
const flattenPage = ( const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup, page: RevisionPageDocument | RevisionPageGroup,
depth: number, depth: number,
): FlatPageEntry[] => { ): FlatPageEntry[] => {
const allowed = filter(page);
if (!allowed) {
return [];
}
return [ return [
...(page.type === 'document' && filter(page) ? [{ page, depth }] : []), ...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) => ...page.pages.flatMap((child) =>
child.type === 'link' ? [] : flattenPage(child, depth + 1), child.type === 'document' ? flattenPage(child, depth + 1) : [],
), ),
]; ];
}; };
return rootPags.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0))); return rootPags.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
);
} }
@@ -1,18 +1,11 @@
import { ContentVisibility } from '@gitbook/api';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import React from 'react'; import React from 'react';
import { getContentPointer } from '@/app/(space)/fetch'; import { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api';
import {
getCollection,
getCurrentSiteCustomization,
getSite,
getSpace,
getSpaceCustomization,
} from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis'; import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils'; import { getContentTitle } from '@/lib/utils';
@@ -42,20 +35,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 = getContentPointer(); const pointer = getSiteContentPointer();
const spaceId = pointer.spaceId; const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([ const [space, customization] = await Promise.all([
getSpace(spaceId), getSpace(spaceId, pointer.siteShareKey),
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId), getCurrentSiteCustomization(pointer),
]); ]);
const parent = const site = await getSite(pointer.organizationId, pointer.siteId);
'siteId' in pointer const contentTitle = getContentTitle(space, customization, site);
? await getSite(pointer.organizationId, pointer.siteId)
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = getContentTitle(space, customization, parent);
return new ImageResponse( return new ImageResponse(
( (
@@ -0,0 +1,223 @@
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',
},
],
},
);
}
+219
View File
@@ -0,0 +1,219 @@
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api';
import { assert } from 'ts-essentials';
import {
getRevisionPageByPath,
getDocument,
ContentTarget,
getSiteData,
getSite,
getCurrentSiteCustomization,
getSiteStructure,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
export interface PagePathParams {
pathname?: string[];
}
export interface PageIdParams {
pageId: string;
}
type SectionsList = { list: SiteSection[]; section: SiteSection };
/**
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = getSiteContentPointer();
const [{ space, contentTarget, pages, customization, scripts }, siteStructure] =
await Promise.all([
getSiteData(content),
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]);
const site = siteStructure.site;
const siteSections =
content.siteSectionId && siteStructure.sections
? parseSiteSectionsList(content.siteSectionId, siteStructure.sections)
: null;
const spaces =
siteStructure.spaces ??
(siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []);
// we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
return {
content,
contentTarget,
space: spaceRelativeToParent ?? space,
pages,
sections: siteSections,
site,
spaces,
customization,
scripts,
ancestors: [],
};
}
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
const section = sections.find((section) => section.id === siteSectionId);
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
return { list: sections, section } satisfies SectionsList;
}
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {
return typeof section !== 'undefined' && section !== null;
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getSiteContentPointer();
const { space, contentTarget, pages, customization, scripts } = await getSiteData(content);
const page = await resolvePage(contentTarget, pages, params);
const [siteStructure, document] = await Promise.all([
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
return {
content,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...page,
...siteStructure,
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(
contentTarget: ContentTarget,
pages: RevisionPage[],
params: PagePathParams | PageIdParams,
) {
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;
}
// If page can't be found, we try with the API, in case we have a redirect
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
//
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
if (rawPathname.length <= 512) {
const resolved = await getRevisionPageByPath(
contentTarget.spaceId,
contentTarget.revisionId,
rawPathname,
);
if (resolved) {
return resolvePageId(pages, resolved.id);
}
}
return undefined;
}
/**
* Fetch the structure of an organization site.
* This includes the site and its sections or spaces.
*/
async function fetchSiteStructure(args: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([
getSite(organizationId, siteId),
getSiteStructure({ organizationId, siteId, siteShareKey }),
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
]);
const siteSections =
siteStructure.type === 'sections' && siteStructure.structure
? siteStructure.structure
: null;
const siteSpaces =
siteStructure.type === 'siteSpaces' && siteStructure.structure
? parseSpacesFromSiteSpaces(siteStructure.structure)
: null;
// override the title with the customization title
const site = {
...orgSite,
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
};
return {
site,
spaces: siteSpaces,
sections: siteSections,
};
}
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
title: siteSpace.title ?? siteSpace.space.title,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return Object.values(spaces);
}
/**
* Get the page path from the params.
*/
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();
}
@@ -0,0 +1,18 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
/**
* Layout to be used for the site root. It fetches the customization data for the site
* and initializes the CustomizationRootLayout with it.
*/
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer);
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
);
}
@@ -1,53 +0,0 @@
import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
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, parent } = await fetchPageData(params);
const url = new URL(space.urls.published ?? space.urls.app);
if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
redirect(customization.socialPreview.url);
}
return new ImageResponse(
(
<div
tw="bg-gray-50 py-16 px-14"
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<h2 tw="text-7xl font-bold tracking-tight text-left">
{getContentTitle(space, customization, parent)}
</h2>
<div tw="flex flex-1">
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
</div>
<div tw="flex">
<p tw="text-4xl">
{url.hostname + (url.pathname.length > 1 ? url.pathname : '')}
</p>
</div>
</div>
),
{
width: 1200,
height: 630,
},
);
}
-227
View File
@@ -1,227 +0,0 @@
import { ContentVisibility, RevisionPage, Space } from '@gitbook/api';
import { headers } from 'next/headers';
import {
getCollectionSpaces,
getCollection,
ContentPointer,
getRevisionPageByPath,
getDocument,
getSpaceData,
ContentTarget,
SiteContentPointer,
getCurrentSiteData,
getSite,
getSiteSpaces,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
export interface PagePathParams {
pathname?: string[];
}
export interface PageIdParams {
pageId: string;
}
/**
* Get the current content pointer from the params.
*/
export function getContentPointer(): ContentPointer | SiteContentPointer {
const headerSet = headers();
const spaceId = headerSet.get('x-gitbook-content-space');
if (!spaceId) {
throw new Error(
'getContentPointer is called outside the scope of a request processed by the middleware',
);
}
const siteId = headerSet.get('x-gitbook-content-site');
if (siteId) {
const organizationId = headerSet.get('x-gitbook-content-organization');
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
if (!organizationId) {
throw new Error('Missing site content headers');
}
const siteContent: SiteContentPointer = {
siteId,
spaceId,
siteSpaceId: siteSpaceId ?? undefined,
siteShareKey: siteShareKey ?? undefined,
organizationId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return siteContent;
} else {
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return content;
}
}
/**
* Fetch all the data needed to render the space layout.
*/
export async function fetchSpaceData() {
const content = getContentPointer();
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
'siteId' in content
? [
getCurrentSiteData(content),
fetchParentSite({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]
: [getSpaceData(content)],
);
const parent = await (parentSite ?? fetchParentCollection(space));
return {
content,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...parent,
};
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
? getCurrentSiteData(content)
: getSpaceData(content));
const page = await resolvePage(contentTarget, pages, params);
const [parent, document] = await Promise.all([
'siteId' in content
? fetchParentSite({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
})
: fetchParentCollection(space),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
return {
content,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...page,
...parent,
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(
contentTarget: ContentTarget,
pages: RevisionPage[],
params: PagePathParams | PageIdParams,
) {
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;
}
// If page can't be found, we try with the API, in case we have a redirect
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
//
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
if (rawPathname.length <= 512) {
const resolved = await getRevisionPageByPath(
contentTarget.spaceId,
contentTarget.revisionId,
rawPathname,
);
if (resolved) {
return resolvePageId(pages, resolved.id);
}
}
return undefined;
}
async function fetchParentCollection(space: Space) {
const parentCollectionId =
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
const [collection, spaces] = await Promise.all([
parentCollectionId ? getCollection(parentCollectionId) : null,
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
]);
return { parent: collection, spaces };
}
async function fetchParentSite(args: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [site, siteSpaces] = await Promise.all([
getSite(organizationId, siteId),
getSiteSpaces({ organizationId, siteId, siteShareKey }),
]);
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return { parent: site, spaces: Object.values(spaces) };
}
/**
* 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,6 +1,6 @@
'use client'; 'use client';
import { AlertTriangle } from '@geist-ui/icons'; import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { useScrollActiveId } from '@/components/hooks'; import { useScrollActiveId } from '@/components/hooks';
@@ -105,7 +105,10 @@ export function PageControlButtons(props: {
'max-w-sm', 'max-w-sm',
)} )}
> >
<AlertTriangle className={tcls('size-6', 'mr-3', 'mt-1')} />{' '} <Icon
icon="triangle-exclamation"
className={tcls('size-6', 'mr-3', 'mt-1')}
/>{' '}
<div> <div>
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div> <div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
<div> <div>
@@ -0,0 +1,38 @@
import { SpaceIntegrationScript } from '@gitbook/api';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData, 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
? getSiteLayoutData(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,18 +1,16 @@
import { ArrowLeft, Printer } from '@geist-ui/icons';
import { import {
CustomizationSettings, CustomizationSettings,
Revision, Revision,
RevisionPageDocument, RevisionPageDocument,
RevisionPageGroup, RevisionPageGroup,
RevisionPageLink,
SiteCustomizationSettings, SiteCustomizationSettings,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
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';
import { getContentPointer } from '@/app/(space)/fetch';
import { DocumentView, createHighlightingContext } from '@/components/DocumentView'; import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark'; import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types'; import { PolymorphicComponentProp } from '@/components/utils/types';
@@ -33,6 +31,7 @@ import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
import './pdf.css'; import './pdf.css';
import { PageControlButtons } from './PageControlButtons'; import { PageControlButtons } from './PageControlButtons';
import { getSiteOrSpacePointerForPDF } from './pointer';
import { PrintButton } from './PrintButton'; import { PrintButton } from './PrintButton';
const DEFAULT_LIMIT = 100; const DEFAULT_LIMIT = 100;
@@ -40,12 +39,12 @@ const DEFAULT_LIMIT = 100;
export const runtime = 'edge'; export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const contentPointer = getContentPointer(); const pointer = getSiteOrSpacePointerForPDF();
const [space, customization] = await Promise.all([ const [space, customization] = await Promise.all([
getSpace(contentPointer.spaceId), getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in contentPointer 'siteId' in pointer
? getCurrentSiteCustomization(contentPointer) ? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(contentPointer.spaceId), : getSpaceCustomization(pointer.spaceId),
]); ]);
return { return {
@@ -58,7 +57,7 @@ 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: { searchParams: { [key: string]: string } }) {
const contentPointer = getContentPointer(); const pointer = getSiteOrSpacePointerForPDF();
const searchParams = new URLSearchParams(props.searchParams); const searchParams = new URLSearchParams(props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams)); const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
@@ -69,10 +68,10 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Load the content, // Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([ const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in contentPointer 'siteId' in pointer
? getCurrentSiteCustomization(contentPointer) ? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(contentPointer.spaceId), : getSpaceCustomization(pointer.spaceId),
getSpaceContentData(contentPointer), getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]); ]);
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
@@ -110,7 +109,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
'border', 'border',
)} )}
> >
<ArrowLeft className={tcls('size-6')} /> <Icon icon="arrow-left" className={tcls('size-6')} />
</a> </a>
</div> </div>
) : null} ) : null}
@@ -136,7 +135,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
'border', 'border',
)} )}
> >
<Printer className={tcls('size-6')} /> <Icon icon="print" className={tcls('size-6')} />
</PrintButton> </PrintButton>
</div> </div>
@@ -170,6 +169,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
space={space} space={space}
page={page} page={page}
refContext={{ refContext={{
siteContext: 'siteId' in pointer ? pointer : null,
space, space,
revisionId: contentTarget.revisionId, revisionId: contentTarget.revisionId,
pages: rootPages, pages: rootPages,
@@ -315,7 +315,7 @@ function selectPages(
return [ return [
{ page, depth }, { page, depth },
...page.pages.flatMap((child) => { ...page.pages.flatMap((child) => {
if (child.type === 'link') { if (child.type !== 'document') {
return []; return [];
} }
@@ -350,7 +350,7 @@ function selectPages(
} }
const allPages = rootPages.flatMap((page) => { const allPages = rootPages.flatMap((page) => {
if (page.type === 'link') { if (page.type !== 'document' && page.type !== 'group') {
return []; return [];
} }
@@ -0,0 +1,17 @@
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();
}
}
@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
// no-cors, *cors, same-origin // no-cors, *cors, same-origin
// mode: 'cors', // Not supported on Cloudflare Workers // mode: 'cors', // Not supported on Cloudflare Workers
// *default, no-cache, reload, force-cache, only-if-cached // *default, no-cache, reload, force-cache, only-if-cached
cache: 'no-cache', // cache: 'no-cache', // Not supported on Cloudflare Workers
// include, *same-origin, omit // include, *same-origin, omit
// credentials: 'include', // Not supported on Cloudflare Workers // credentials: 'include', // Not supported on Cloudflare Workers
headers: requestBody.headers, headers: requestBody.headers,
@@ -1,11 +1,16 @@
import { Space } from '@gitbook/api'; import { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { ContentPointer, getChangeRequest, getRevision } from '@/lib/api'; import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
import { DateRelative } from '../primitives';
interface AdminToolbarProps { interface AdminToolbarProps {
content: ContentPointer; content: SiteContentPointer;
space: Space; space: Space;
} }
@@ -67,9 +72,30 @@ async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: s
const changeRequest = await getChangeRequest(spaceId, changeRequestId); const changeRequest = await getChangeRequest(spaceId, changeRequestId);
return ( return (
<ToolbarButton href={changeRequest.urls.app}> <Toolbar>
Change request #{changeRequest.number}: {changeRequest.subject ?? 'No subject'} <ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton> </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}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
); );
} }
@@ -77,37 +103,35 @@ async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
const { spaceId, revisionId } = props; const { spaceId, revisionId } = props;
const revision = await getRevision(spaceId, revisionId, { const revision = await getRevision(spaceId, revisionId, {
metadata: false, metadata: true,
}); });
return ( return (
<ToolbarButton href={revision.urls.app}> <Toolbar>
Revision created on {new Date(revision.createdAt).toLocaleDateString()} <ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton> </ToolbarButton>
); <ToolbarBody>
} <p>
Revision created <DateRelative value={revision.createdAt} />
function ToolbarButton(props: { href: string; children: React.ReactNode }) { </p>
const { href, children } = props; {revision.git ? (
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
return ( ) : null}
<a </ToolbarBody>
href={href} <ToolbarButtonGroups>
className={tcls( <ToolbarButton title="Open in application" href={revision.urls.app}>
'block', <Icon icon="arrow-up-right-from-square" className="size-4" />
'text-sm', </ToolbarButton>
'px-4', {revision.git?.url ? (
'py-1', <ToolbarButton title="Open git commit" href={revision.git.url}>
'rounded-full', <Icon
'hover:bg-dark-1', icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
'hover:text-white', className="size-4"
'truncate', />
'text-light', </ToolbarButton>
'dark:text-light', ) : null}
'dark:hover:bg-dark-2', </ToolbarButtonGroups>
)} </Toolbar>
>
{children}
</a>
); );
} }
@@ -0,0 +1,70 @@
'use client';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
import { tcls } from '@/lib/tailwind';
import { ToolbarButton } from './Toolbar';
// We don't show the button if the content has been updated 30s ago or less.
const minInterval = 1000 * 30; // 5 minutes
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshChangeRequestButton(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
updatedAt: number;
}) {
const { updatedAt } = props;
const [visible, setVisible] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const checkForUpdates = useCheckForContentUpdate(props);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
await checkForUpdates();
} finally {
setLoading(false);
setVisible(false);
}
}, [checkForUpdates]);
// Show the button if the content has been updated more than 30s ago.
React.useEffect(() => {
if (updatedAt < Date.now() - minInterval) {
setVisible(true);
}
}, [updatedAt]);
// 30sec after being hidden, we show the button again
React.useEffect(() => {
if (!visible) {
const timeout = setTimeout(() => {
setVisible(true);
}, minInterval);
return () => clearTimeout(timeout);
}
}, [visible]);
if (!visible) {
return null;
}
return (
<ToolbarButton
title="Refresh"
onClick={(event) => {
event.preventDefault();
refresh();
}}
>
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
</ToolbarButton>
);
}
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { tcls } from '@/lib/tailwind';
export function Toolbar(props: { children: React.ReactNode }) {
const { children } = props;
return (
<div
className={tcls(
'flex',
'flex-row',
'items-center',
'gap-4',
'text-sm',
'px-4',
'py-1',
'rounded-full',
'truncate',
'text-light',
'dark:text-light',
)}
>
{children}
</div>
);
}
export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col gap-1">{props.children}</div>;
}
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
return <div className="flex flex-row gap-2">{props.children}</div>;
}
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
const { children, ...rest } = props;
return (
<a
{...rest}
className={tcls(
'flex',
'flex-col',
'items-center',
'justify-center',
'size-11',
'gap-1',
'text-sm',
'rounded-full',
'hover:bg-dark-1',
'hover:text-white',
'truncate',
'text-light',
'dark:text-light',
'dark:hover:bg-dark-2',
'hover:shadow-lg',
'cursor-pointer',
)}
>
{children}
</a>
);
}
+57 -104
View File
@@ -1,13 +1,17 @@
'use client'; 'use client';
import IconHeart from '@geist-ui/icons/heart'; import { SiteAds, SiteAdsStatus } from '@gitbook/api';
import * as React from 'react'; import * as React from 'react';
import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind'; import { ClassValue, tcls } from '@/lib/tailwind';
import { AdClassicRendering } from './AdClassicRendering'; import { renderAd } from './renderAd';
import { AdCoverRendering } from './AdCoverRendering';
import { AdItem, AdsResponse } from './types'; /**
* Zone ID provided by BuySellAds for the preview.
*/
const PREVIEW_ZONE_ID = 'CVAIKKQM';
/** /**
* Fetch and render the Ad placement. * Fetch and render the Ad placement.
@@ -18,20 +22,21 @@ export function Ad({
spaceId, spaceId,
placement, placement,
ignore, ignore,
siteAdsStatus,
style, style,
mode = 'auto', mode = 'auto',
}: { }: {
zoneId: string; zoneId: string | null;
spaceId: string; spaceId: string;
placement: string; placement: string;
ignore: boolean; ignore: boolean;
style?: ClassValue; style?: ClassValue;
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 [visible, setVisible] = React.useState(false);
const [failed, setFailed] = React.useState(false); const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
const [ad, setAd] = React.useState<AdItem | undefined>(undefined);
// Observe the container visibility // Observe the container visibility
React.useEffect(() => { React.useEffect(() => {
@@ -68,55 +73,68 @@ export function Ad({
let cancelled = false; let cancelled = false;
(async () => { const previewParam = new URL(window.location.href).searchParams.get('ads_preview');
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`); const preview = !!previewParam;
url.searchParams.set('segment', `placement:${placement}`); const realZoneId = preview ? PREVIEW_ZONE_ID : zoneId;
url.searchParams.set('v', 'true'); const showPlaceholderAd =
if (ignore) { previewParam === 'placeholder' ||
url.searchParams.set('ignore', 'true'); (siteAdsStatus &&
(siteAdsStatus === SiteAdsStatus.Pending ||
siteAdsStatus === SiteAdsStatus.InReview));
if (!realZoneId && !showPlaceholderAd) {
return;
} }
try { (async () => {
const res = await fetch(url); const result = showPlaceholderAd
const json: AdsResponse = await res.json(); ? await renderAd({ source: 'placeholder' })
: realZoneId
? await renderAd({
placement,
ignore: ignore || preview,
zoneId: realZoneId,
mode,
source: 'live',
})
: undefined;
if (cancelled) { if (cancelled) {
return; return;
} }
const first = json.ads[0]; if (result) {
if (first && 'active' in first) { setAd(result);
setAd(first);
}
} catch (error) {
console.error(
'Failed to fetch ad, it might have been blocked by a ad-blocker',
error,
);
setFailed(true);
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [visible, zoneId, ignore, placement]); }, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? (
<>
{ad}
<AdSponsoredLink spaceId={spaceId} />
</>
) : null}
</div>
);
}
function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props;
const language = useLanguage();
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');
viaUrl.searchParams.set('utm_medium', 'ads'); viaUrl.searchParams.set('utm_medium', 'sponsored-by-gitbook');
viaUrl.searchParams.set('utm_campaign', spaceId); viaUrl.searchParams.set('utm_campaign', spaceId);
return ( return (
<div ref={containerRef} className={tcls(style)}>
{ad && ad.description && ad.statlink ? (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
<p <p
className={tcls( className={tcls(
'mt-2', 'mt-2',
@@ -127,74 +145,9 @@ export function Ad({
'dark:text-light/5', 'dark:text-light/5',
)} )}
> >
<a <a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
target="_blank" {t(language, 'sponsored_via_gitbook')}
href={viaUrl.toString()}
className={tcls('hover:underline')}
>
Ads via GitBook
</a> </a>
</p> </p>
</>
) : failed ? (
<AdBlockerPlaceholder />
) : null}
</div>
);
}
/**
* Render attribution or verification pixels.
* https://docs.buysellads.com/ad-serving-api#pixels
*/
function AdPixels({ rawPixel }: { rawPixel: string }) {
const pixels = rawPixel.split('||');
const time = String(Math.round(Date.now() / 1e4) | 0);
return (
<div className={tcls('hidden')}>
{pixels.map((pixel, index) => {
return (
<img
key={index}
src={pixel.replace('[timestamp]', time)}
width="1"
height="1"
style={{ display: 'none' }}
alt="Ads tracking pixel"
/>
);
})}
</div>
);
}
/**
* Placeholder when visitor has an ad-blocker.
*/
function AdBlockerPlaceholder() {
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'rounded-lg',
'p-4',
)}
>
<div className={tcls('flex', 'flex-row', 'gap-2', 'items-center')}>
<IconHeart className={tcls('w-4', 'h-4', 'text-primary-500')} />
<p className={tcls('text-xs', 'font-semibold')}>Ad disabled</p>
</div>
<p className={tcls('text-xs')}>
{`It looks like you're using an adblocker. Whitelist this site to help support this
project.`}
</p>
</div>
); );
} }
@@ -1,5 +1,6 @@
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';
@@ -29,14 +30,21 @@ export function AdClassicRendering({ ad }: { ad: AdItem }) {
> >
{'smallImage' in ad ? ( {'smallImage' in ad ? (
<div> <div>
<img alt="Ads logo" className={tcls('rounded-md')} src={ad.smallImage} /> <img
alt="Ads logo"
className={tcls('rounded-md')}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
</div> </div>
) : ( ) : (
<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 alt="Ads logo" src={ad.logo} /> <img
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
</div> </div>
)} )}
<div className={tcls('flex', 'flex-col')}> <div className={tcls('flex', 'flex-col')}>
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { hexToRgba } from '@/lib/colors'; 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';
@@ -9,6 +10,8 @@ import { AdCover } from './types';
* Cover rendering for an ad. * Cover rendering for an ad.
*/ */
export function AdCoverRendering({ ad }: { ad: AdCover }) { export function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return ( return (
<a <a
className={tcls( className={tcls(
@@ -43,14 +46,14 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
'z-0', 'z-0',
)} )}
style={{ style={{
backgroundImage: `url(${ad.largeImage})`, backgroundImage: `url(${largeImage})`,
}} }}
/> />
<div className={tcls('z-[2]')}> <div className={tcls('z-[2]')}>
<img <img
alt="Large image" alt="Large image"
src={ad.largeImage} src={largeImage}
className={tcls( className={tcls(
'rounded-md', 'rounded-md',
'shadow-md', 'shadow-md',
@@ -0,0 +1,29 @@
import * as React from 'react';
import { tcls } from '@/lib/tailwind';
/**
* Render attribution or verification pixels.
* https://docs.buysellads.com/ad-serving-api#pixels
*/
export function AdPixels({ rawPixel }: { rawPixel: string }) {
const pixels = rawPixel.split('||');
const time = String(Math.round(Date.now() / 1e4) | 0);
return (
<div className={tcls('hidden')}>
{pixels.map((pixel, index) => {
return (
<img
key={index}
src={pixel.replace('[timestamp]', time)}
width="1"
height="1"
style={{ display: 'none' }}
alt="Ads tracking pixel"
/>
);
})}
</div>
);
}
@@ -0,0 +1,62 @@
<svg width="193" height="120" viewBox="0 0 193 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M183.432 0.776855H10.2788C5.07442 0.776855 0.855469 4.14309 0.855469 8.29556V111.678C0.855469 115.831 5.07442 119.197 10.2788 119.197H183.432C188.637 119.197 192.855 115.831 192.855 111.678V8.29556C192.855 4.14309 188.637 0.776855 183.432 0.776855Z"
fill="#14171C" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M96.8556 72.2159C84.724 72.2159 74.8894 82.0505 74.8894 94.1818C74.8894 95.9661 73.4431 97.4124 71.659 97.4124C69.875 97.4124 68.4287 95.9661 68.4287 94.1818C68.4287 78.4825 81.1558 65.7554 96.8556 65.7554C112.556 65.7554 125.282 78.4825 125.282 94.1818C125.282 95.9661 123.836 97.4124 122.053 97.4124C120.268 97.4124 118.822 95.9661 118.822 94.1818C118.822 82.0505 108.987 72.2159 96.8556 72.2159Z"
fill="url(#paint0_radial_3089_1090)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M129.621 94.1816C129.621 76.0865 114.951 61.4172 96.8558 61.4172C78.7603 61.4172 64.091 76.0865 64.091 94.1816C64.091 95.9658 62.6447 97.4122 60.8607 97.4122C59.0766 97.4122 57.6304 95.9658 57.6304 94.1816C57.6304 72.5183 75.1922 54.9565 96.8558 54.9565C118.52 54.9565 136.081 72.5183 136.081 94.1816C136.081 95.9658 134.635 97.4122 132.85 97.4122C131.067 97.4122 129.621 95.9658 129.621 94.1816Z"
fill="url(#paint1_radial_3089_1090)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M96.856 50.3419C72.6438 50.3419 53.0159 69.9698 53.0159 94.1818C53.0159 95.966 51.5695 97.4124 49.7855 97.4124C48.0014 97.4124 46.5552 95.966 46.5552 94.1818C46.5552 66.4018 69.0756 43.8813 96.856 43.8813C124.637 43.8813 147.157 66.4018 147.157 94.1818C147.157 95.966 145.711 97.4124 143.927 97.4124C142.142 97.4124 140.696 95.966 140.696 94.1818C140.696 69.9698 121.068 50.3419 96.856 50.3419Z"
fill="url(#paint2_radial_3089_1090)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M151.587 94.1815C151.587 63.9548 127.083 39.4509 96.8557 39.4509C66.6286 39.4509 42.1246 63.9548 42.1246 94.1815C42.1246 95.9658 40.6784 97.4121 38.8943 97.4121C37.1103 97.4121 35.6641 95.9658 35.6641 94.1815C35.6641 60.3866 63.0605 32.9902 96.8557 32.9902C130.651 32.9902 158.047 60.3866 158.047 94.1815C158.047 95.9658 156.601 97.4121 154.818 97.4121C153.033 97.4121 151.587 95.9658 151.587 94.1815Z"
fill="url(#paint3_radial_3089_1090)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M96.8554 29.0217C60.8683 29.0217 31.6951 58.195 31.6951 94.1817C31.6951 95.966 30.2488 97.4123 28.4647 97.4123C26.6807 97.4123 25.2344 95.966 25.2344 94.1817C25.2344 54.6269 57.3003 22.561 96.8554 22.561C136.411 22.561 168.477 54.6269 168.477 94.1817C168.477 95.966 167.03 97.4123 165.246 97.4123C163.462 97.4123 162.015 95.966 162.015 94.1817C162.015 58.195 132.842 29.0217 96.8554 29.0217Z"
fill="url(#paint4_radial_3089_1090)" />
<defs>
<radialGradient id="paint0_radial_3089_1090" cx="0" cy="0" r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(88.9053 80.4777) rotate(64.8515) scale(18.7082 33.8612)">
<stop stop-color="#3F89A1" />
<stop offset="0.339725" stop-color="#F4E28D" />
<stop offset="0.505624" stop-color="#BBDDE5" />
<stop offset="0.848958" stop-color="#FDA599" />
</radialGradient>
<radialGradient id="paint1_radial_3089_1090" cx="0" cy="0" r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(143.478 58.0317) rotate(168.06) scale(87.7455 57.2588)">
<stop stop-color="#3F89A1" />
<stop offset="0.19" stop-color="#F4E28D" />
<stop offset="0.614011" stop-color="#BBDDE5" />
<stop offset="1" stop-color="#FDA599" />
</radialGradient>
<radialGradient id="paint2_radial_3089_1090" cx="0" cy="0" r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(82.7879 68.7762) rotate(63.8366) scale(31.9052 59.4091)">
<stop stop-color="#3F89A1" />
<stop offset="0.339725" stop-color="#F4E28D" />
<stop offset="0.505624" stop-color="#BBDDE5" />
<stop offset="0.848958" stop-color="#FDA599" />
</radialGradient>
<radialGradient id="paint3_radial_3089_1090" cx="0" cy="0" r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(101.132 56.1925) rotate(172.165) scale(66.0854 41.9909)">
<stop stop-color="#3F89A1" />
<stop offset="0.339725" stop-color="#F4E28D" />
<stop offset="0.505624" stop-color="#BBDDE5" />
<stop offset="0.848958" stop-color="#FDA599" />
</radialGradient>
<radialGradient id="paint4_radial_3089_1090" cx="0" cy="0" r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(76.8246 57.371) rotate(63.4233) scale(44.7721 84.288)">
<stop stop-color="#3F89A1" />
<stop offset="0.339725" stop-color="#F4E28D" />
<stop offset="0.505624" stop-color="#BBDDE5" />
<stop offset="0.848958" stop-color="#FDA599" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,134 @@
'use server';
import { headers } from 'next/headers';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
import { AdPixels } from './AdPixels';
import adRainbow from './assets/ad-rainbow.svg';
import { AdItem, AdsResponse } from './types';
type FetchAdOptions = FetchLiveAdOptions | FetchPlaceholderAdOptions;
interface FetchLiveAdOptions {
/**
* Source of the ad (live: from the platform)
*/
source: 'live';
/** ID of the zone to fetch Ads for */
zoneId: string;
/** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */
placement: string;
/** If true, we'll not track it as an impression */
ignore: boolean;
}
interface FetchPlaceholderAdOptions {
/**
* Source of the ad (placeholder: static placeholder ad)
*/
source: 'placeholder';
}
/**
* Server action to render the Ad placement.
* We use a server-action to avoid caching issues with server-side components,
* and properly access user-agent and IP.
*/
export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
const { ad } = result;
return (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
);
}
async function fetchAd({
zoneId,
placement,
ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
const { ip, userAgent } = getUserAgentAndIp();
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
url.searchParams.set('v', 'true');
url.searchParams.set('forwardedip', ip);
url.searchParams.set('useragent', userAgent);
if (ignore) {
url.searchParams.set('ignore', 'true');
}
const res = await fetch(url);
const json: AdsResponse = await res.json();
const first = json.ads[0];
if (first && 'active' in first) {
return { ad: first, ip };
}
return null;
}
function getPlaceholderAd(): { ad: AdItem; ip: string } {
const { ip } = getUserAgentAndIp();
return {
ad: {
active: '1',
ad_via_link: '',
bannerid: '',
creativeid: '',
description:
'Your docs could be this good.\nPublish incredible open source docs for free with GitBook',
evenodd: '0',
external_id: '',
height: '0',
i: '0',
identifier: '',
longimp: '',
longlink: '',
num_slots: '1',
rendering: 'carbon',
smallImage: adRainbow.src,
statimp: '',
statlink:
'https://www.gitbook.com/solutions/open-source?utm_campaign=sponsored-content&utm_medium=ad&utm_source=content',
timestamp: Date.now().toString(),
width: '0',
zoneid: '',
zonekey: '',
},
ip,
};
}
function getUserAgentAndIp() {
const headersSet = headers();
const ip =
headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ??
headersSet.get('cf-pseudo-ipv4') ??
headersSet.get('cf-connecting-ip') ??
headersSet.get('x-forwarded-for') ??
'';
const userAgent = headersSet.get('user-agent') ?? '';
return { ip, userAgent };
}
@@ -0,0 +1 @@
export * from './useCheckForContentUpdate';
@@ -0,0 +1,15 @@
'use server';
import { getChangeRequest } from '@/lib/api';
/**
* Return true if a change-request has been updated.
*/
export async function hasContentBeenUpdated(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
return changeRequest.revision !== props.revisionId;
}
@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { hasContentBeenUpdated } from './server-actions';
/**
* Return a callback to check if a change request has been updated and to refresh the page if it has.
*/
export function useCheckForContentUpdate(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
const { spaceId, changeRequestId, revisionId } = props;
return React.useCallback(async () => {
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
if (updated) {
window.location.reload();
}
}, [spaceId, changeRequestId, revisionId]);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import IconX from '@geist-ui/icons/x'; import { Icon } from '@gitbook/icons';
import * as React from 'react'; import * as React from 'react';
import { Button } from '@/components/primitives'; import { Button } from '@/components/primitives';
@@ -27,7 +27,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
const onUpdateState = (enabled: boolean) => { const onUpdateState = (enabled: boolean) => {
setCookiesTracking(enabled); setCookiesTracking(enabled);
// Reload the page to take the change in consideration // Reload the page to take the change in consideration
window.location.reload(); window.location.reload();
}; };
@@ -75,6 +74,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
)} )}
</p> </p>
<button <button
type="button"
onClick={() => setShow(false)} onClick={() => setShow(false)}
aria-label={tString(language, 'cookies_close')} aria-label={tString(language, 'cookies_close')}
className={tcls( className={tcls(
@@ -92,7 +92,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
)} )}
title={tString(language, 'cookies_close')} title={tString(language, 'cookies_close')}
> >
<IconX className={tcls('w-4')} /> <Icon icon="xmark" className={tcls('size-4')} />
</button> </button>
<div className={tcls('mt-3', 'flex', 'flex-row', 'gap-2')}> <div className={tcls('mt-3', 'flex', 'flex-row', 'gap-2')}>
<Button <Button
@@ -27,6 +27,7 @@ export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
ancestorBlocks={[]} ancestorBlocks={[]}
context={context} context={context}
nodes={fragment.nodes} nodes={fragment.nodes}
style={['space-y-4']}
/> />
} }
> >
@@ -1,5 +1,4 @@
import { DocumentBlock, JSONDocument } from '@gitbook/api'; import { DocumentBlock, JSONDocument } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react'; import React from 'react';
import { import {
@@ -12,7 +11,6 @@ import {
import { ClassValue } from '@/lib/tailwind'; import { ClassValue } from '@/lib/tailwind';
import { BlockContentRef } from './BlockContentRef'; import { BlockContentRef } from './BlockContentRef';
import { BlockSyncedBlock } from './BlockSyncedBlock';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { Divider } from './Divider'; import { Divider } from './Divider';
import { DocumentContextProps } from './DocumentView'; import { DocumentContextProps } from './DocumentView';
@@ -32,6 +30,9 @@ import { BlockMath } from './Math';
import { OpenAPI } from './OpenAPI'; import { OpenAPI } from './OpenAPI';
import { Paragraph } from './Paragraph'; import { Paragraph } from './Paragraph';
import { Quote } from './Quote'; import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent';
import { Stepper } from './Stepper';
import { StepperStep } from './StepperStep';
import { Table } from './Table'; import { Table } from './Table';
import { Tabs } from './Tabs'; import { Tabs } from './Tabs';
@@ -39,77 +40,100 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext
block: Block; block: Block;
document: JSONDocument; document: JSONDocument;
ancestorBlocks: DocumentBlock[]; ancestorBlocks: DocumentBlock[];
/** If true, we estimate that the block will be outside the initial viewport */
isEstimatedOffscreen: boolean;
/** Class names to be passed to the underlying DOM element */
style?: ClassValue; style?: ClassValue;
} }
/**
* Alternative to `assertNever` that returns `null` instead of throwing an error.
*/
function nullIfNever(value: never): null {
return null;
}
export function Block<T extends DocumentBlock>(props: BlockProps<T>) { export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
const { block, style, ...contextProps } = props; const { block, style, isEstimatedOffscreen, context } = props;
const content = (() => { const content = (() => {
switch (block.type) { switch (block.type) {
case 'paragraph': case 'paragraph':
return <Paragraph {...props} {...contextProps} block={block} />; return <Paragraph {...props} block={block} />;
case 'heading-1': case 'heading-1':
case 'heading-2': case 'heading-2':
case 'heading-3': case 'heading-3':
return <Heading {...props} {...contextProps} block={block} />; return <Heading {...props} block={block} />;
case 'list-ordered': case 'list-ordered':
return <ListOrdered {...props} {...contextProps} block={block} />; return <ListOrdered {...props} block={block} />;
case 'list-unordered': case 'list-unordered':
return <ListUnordered {...props} {...contextProps} block={block} />; return <ListUnordered {...props} block={block} />;
case 'list-tasks': case 'list-tasks':
return <ListTasks {...props} {...contextProps} block={block} />; return <ListTasks {...props} block={block} />;
case 'list-item': case 'list-item':
return <ListItem {...props} {...contextProps} block={block} />; return <ListItem {...props} block={block} />;
case 'code': case 'code':
return <CodeBlock {...props} {...contextProps} block={block} />; return <CodeBlock {...props} block={block} />;
case 'hint': case 'hint':
return <Hint {...props} {...contextProps} block={block} />; return <Hint {...props} block={block} />;
case 'images': case 'images':
return <Images {...props} {...contextProps} block={block} />; return <Images {...props} block={block} />;
case 'tabs': case 'tabs':
return <Tabs {...props} {...contextProps} block={block} />; return <Tabs {...props} block={block} />;
case 'expandable': case 'expandable':
return <Expandable {...props} {...contextProps} block={block} />; return <Expandable {...props} block={block} />;
case 'table': case 'table':
return <Table {...props} {...contextProps} block={block} />; return <Table {...props} block={block} />;
case 'swagger': case 'swagger':
return <OpenAPI {...props} {...contextProps} block={block} />; return <OpenAPI {...props} block={block} />;
case 'embed': case 'embed':
return <Embed {...props} {...contextProps} block={block} />; return <Embed {...props} block={block} />;
case 'blockquote': case 'blockquote':
return <Quote {...props} {...contextProps} block={block} />; return <Quote {...props} block={block} />;
case 'math': case 'math':
return <BlockMath {...props} {...contextProps} block={block} />; return <BlockMath {...props} block={block} />;
case 'file': case 'file':
return <File {...props} {...contextProps} block={block} />; return <File {...props} block={block} />;
case 'divider': case 'divider':
return <Divider {...props} {...contextProps} block={block} />; return <Divider {...props} block={block} />;
case 'drawing': case 'drawing':
return <Drawing {...props} {...contextProps} block={block} />; return <Drawing {...props} block={block} />;
case 'content-ref': case 'content-ref':
return <BlockContentRef {...props} {...contextProps} block={block} />; return <BlockContentRef {...props} block={block} />;
case 'image': case 'image':
case 'code-line': case 'code-line':
case 'tabs-item': case 'tabs-item':
throw new Error('Blocks should be directly rendered by parent'); throw new Error('Blocks should be directly rendered by parent');
case 'integration': case 'integration':
return <IntegrationBlock {...props} {...contextProps} block={block} />; return <IntegrationBlock {...props} block={block} />;
case 'synced-block': case 'reusable-content':
return <BlockSyncedBlock {...props} {...contextProps} block={block} />; return <ReusableContent {...props} block={block} />;
case 'stepper':
return <Stepper {...props} block={block} />;
case 'stepper-step':
return <StepperStep {...props} block={block} />;
default: default:
assertNever(block); return nullIfNever(block);
} }
})(); })();
if (!isEstimatedOffscreen || context.wrapBlocksInSuspense === false) {
// When blocks are estimated to be on the initial viewport, we render them immediately
// to avoid a flash of a loading skeleton.
return content;
}
return ( return (
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}> <React.Suspense fallback={<BlockSkeleton block={block} style={style} />}>
{content} {content}
</React.Suspense> </React.Suspense>
); );
} }
function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) { /**
* Skeleton for a block while it is being loaded.
*/
export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }) {
const { block, style } = props; const { block, style } = props;
const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined; const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined;
@@ -129,7 +153,6 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
case 'code': case 'code':
case 'hint': case 'hint':
case 'tabs': case 'tabs':
case 'synced-block':
return <SkeletonParagraph id={id} style={style} />; return <SkeletonParagraph id={id} style={style} />;
case 'expandable': case 'expandable':
case 'table': case 'table':
@@ -138,6 +161,8 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
case 'divider': case 'divider':
case 'content-ref': case 'content-ref':
case 'integration': case 'integration':
case 'stepper':
case 'reusable-content':
return <SkeletonCard id={id} style={style} />; return <SkeletonCard id={id} style={style} />;
case 'embed': case 'embed':
case 'images': case 'images':
@@ -146,8 +171,9 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
case 'image': case 'image':
case 'code-line': case 'code-line':
case 'tabs-item': case 'tabs-item':
case 'stepper-step':
throw new Error('Blocks should be directly rendered by parent'); throw new Error('Blocks should be directly rendered by parent');
default: default:
assertNever(block); return nullIfNever(block);
} }
} }
@@ -1,17 +1,18 @@
import { DocumentBlockContentRef } from '@gitbook/api'; import { DocumentBlockContentRef } from '@gitbook/api';
import { LogoIcon } from '@/components/icons';
import { Card, Emoji } from '@/components/primitives'; import { Card, Emoji } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api'; import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references'; import { ResolvedContentRef } from '@/lib/references';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { SpaceIcon } from '../Space/SpaceIcon';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) { export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props; const { block, context, style } = props;
const resolved = await context.resolveContentRef(block.data.ref, { const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true, resolveAnchorText: true,
iconStyle: ['text-xl', 'text-dark/6', 'dark:text-light/6'],
}); });
if (!resolved) { if (!resolved) {
@@ -29,7 +30,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
return ( return (
<Card <Card
leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} style="text-xl" /> : null} leadingIcon={resolved.icon ? resolved.icon : null}
href={resolved.href} href={resolved.href}
title={resolved.text} title={resolved.text}
style={style} style={style}
@@ -55,7 +56,7 @@ async function SpaceRefCard(
return ( return (
<Card <Card
leadingIcon={ leadingIcon={
<LogoIcon <SpaceIcon
icon={customIcon} icon={customIcon}
emoji={customEmoji} emoji={customEmoji}
alt="" alt=""
@@ -1,53 +0,0 @@
import { DocumentBlockSyncedBlock } from '@gitbook/api';
import { getSyncedBlockContent } from '@/lib/api';
import { resolveContentRefWithFiles } from '@/lib/references';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export async function BlockSyncedBlock(props: BlockProps<DocumentBlockSyncedBlock>) {
const { block, ancestorBlocks, context, style } = props;
const apiToken = block.meta?.apiToken;
if (!apiToken) {
return null;
}
// We can't resolve the synced block without an organization context.
if (!context.contentRefContext) {
return null;
}
const syncedBlock = await getSyncedBlockContent(
apiToken,
context.contentRefContext.space.organization,
block.data.ref.syncedBlock,
);
if (!syncedBlock) {
return null;
}
return (
<Blocks
nodes={syncedBlock.document.nodes}
document={syncedBlock.document}
ancestorBlocks={[...ancestorBlocks, block]}
context={{
...context,
resolveContentRef: async (ref, options) => {
if (!syncedBlock?.files) {
return context.resolveContentRef(ref, options);
}
const result = resolveContentRefWithFiles(syncedBlock.files, ref);
if (result !== undefined) {
return result;
}
return context.resolveContentRef(ref, options);
},
}}
style={style}
/>
);
}
@@ -4,11 +4,35 @@ import { tcls, ClassValue } from '@/lib/tailwind';
import { Block } from './Block'; import { Block } from './Block';
import { DocumentContextProps } from './DocumentView'; import { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';
export function Blocks<T extends DocumentBlock, Tag extends React.ElementType = 'div'>( /**
props: DocumentContextProps & { * Renders a list of blocks with a wrapper element.
*/
export function Blocks<TBlock extends DocumentBlock, Tag extends React.ElementType = 'div'>(
props: UnwrappedBlocksProps<TBlock> & {
/** HTML tag to use for the wrapper */
tag?: Tag;
/** Style passed to the wrapper */
style?: ClassValue;
/** Props to pass to the wrapper element */
wrapperProps?: React.ComponentProps<Tag>;
},
) {
const { tag: Tag = 'div', style, wrapperProps, ...blocksProps } = props;
return (
<Tag {...wrapperProps} className={tcls(style)}>
<UnwrappedBlocks {...blocksProps} />
</Tag>
);
}
type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps & {
/** Blocks to render */ /** Blocks to render */
nodes: T[]; nodes: TBlock[];
/** Document being rendered */ /** Document being rendered */
document: JSONDocument; document: JSONDocument;
@@ -16,36 +40,45 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
/** Ancestors of the blocks */ /** Ancestors of the blocks */
ancestorBlocks: DocumentBlock[]; ancestorBlocks: DocumentBlock[];
/** HTML tag to use */
tag?: Tag;
/** Style passed to the wrapper */
style?: ClassValue;
/** Style passed to all blocks */ /** Style passed to all blocks */
blockStyle?: ClassValue; blockStyle?: ClassValue;
}, };
) {
const { nodes, tag: Tag = 'div', style, blockStyle, ...contextProps } = props; /**
* Renders a list of blocks without a wrapper element.
*/
export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBlocksProps<TBlock>) {
const { nodes, blockStyle, ...contextProps } = props;
let isOffscreen = false;
return (
<>
{nodes.map((node) => {
isOffscreen =
isOffscreen ||
isBlockOffscreen({
document: props.document,
block: node,
ancestorBlocks: props.ancestorBlocks,
});
return ( return (
<Tag className={tcls(style)}>
{nodes.map((node, index) => (
<Block <Block
key={node.key} key={node.key}
block={node} block={node}
style={[ style={[
'w-full mx-auto decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl' ? 'max-w-screen-xl'
: 'max-w-3xl', : 'max-w-3xl',
'w-full',
'mx-auto',
'decoration-primary/6',
blockStyle, blockStyle,
]} ]}
isEstimatedOffscreen={isOffscreen}
{...contextProps} {...contextProps}
/> />
))} );
</Tag> })}
</>
); );
} }
@@ -1,6 +1,7 @@
import { import {
DocumentBlockDrawing, DocumentBlockDrawing,
DocumentBlockEmbed, DocumentBlockEmbed,
DocumentBlockFile,
DocumentBlockImage, DocumentBlockImage,
JSONDocument, JSONDocument,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -21,7 +22,7 @@ export function Caption(
style?: ClassValue; style?: ClassValue;
fit?: boolean; fit?: boolean;
wrapperStyle?: ClassValue; wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed; block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
} & DocumentContextProps, } & DocumentContextProps,
) { ) {
const { const {
@@ -54,6 +54,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}} }}
block={block} block={block}
ancestorBlocks={[]} ancestorBlocks={[]}
isEstimatedOffscreen={false}
/> />
); );
} }
@@ -1,13 +1,12 @@
import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api'; import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api';
import { import {
loadWasm, createdBundledHighlighter,
ThemedToken, ThemedToken,
getHighlighter,
createCssVariablesTheme, createCssVariablesTheme,
HighlighterGeneric, HighlighterGeneric,
bundledLanguages, } from 'shiki/core';
bundledThemes, import { loadWasm, createOnigurumaEngine } from 'shiki/engine/oniguruma';
} from 'shiki'; import { bundledLanguages } from 'shiki/langs';
// @ts-ignore - onigWasm is a Wasm module // @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module'; import onigWasm from 'shiki/onig.wasm?module';
@@ -15,8 +14,6 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document'; import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing'; import { trace } from '@/lib/tracing';
import { DocumentContext } from '../DocumentView';
export type HighlightLine = { export type HighlightLine = {
highlighted: boolean; highlighted: boolean;
tokens: HighlightToken[]; tokens: HighlightToken[];
@@ -299,6 +296,12 @@ function cleanupLine(line: string): string {
return line.replace(/\r/g, ''); return line.replace(/\r/g, '');
} }
const createHighlighter = createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
engine: () => createOnigurumaEngine(import('shiki/wasm')),
});
/** /**
* Load the highlighter, only once, and reuse it. * Load the highlighter, only once, and reuse it.
* It makes sure to handle concurrent calls. * It makes sure to handle concurrent calls.
@@ -312,7 +315,8 @@ const loadHighlighter = singleton(async () => {
// Otherwise for Vercel/Cloudflare, we need to load it ourselves. // Otherwise for Vercel/Cloudflare, we need to load it ourselves.
await loadWasm((obj) => WebAssembly.instantiate(onigWasm, obj)); await loadWasm((obj) => WebAssembly.instantiate(onigWasm, obj));
} }
const highlighter = await getHighlighter({
const highlighter = await createHighlighter({
themes: [createCssVariablesTheme()], themes: [createCssVariablesTheme()],
langs: [], langs: [],
}); });
@@ -322,7 +326,7 @@ const loadHighlighter = singleton(async () => {
const loadLanguagesMutex = asyncMutexFunction(); const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage( async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<keyof typeof bundledLanguages, keyof typeof bundledThemes>, highlighter: HighlighterGeneric<any, any>,
lang: keyof typeof bundledLanguages, lang: keyof typeof bundledLanguages,
) { ) {
await loadLanguagesMutex.runBlocking(async () => { await loadLanguagesMutex.runBlocking(async () => {
@@ -9,6 +9,9 @@
--shiki-token-string-expression: #6a4906; --shiki-token-string-expression: #6a4906;
--shiki-token-punctuation: theme('colors.pomegranate.700/.92'); --shiki-token-punctuation: theme('colors.pomegranate.700/.92');
--shiki-token-link: theme('colors.dark.DEFAULT'); --shiki-token-link: theme('colors.dark.DEFAULT');
--shiki-token-inserted: #22863a;
--shiki-token-deleted: #b31d28;
--shiki-token-changed: #8250df;
} }
html.dark { html.dark {
@@ -22,4 +25,7 @@ html.dark {
--shiki-token-string-expression: theme('colors.light.4'); --shiki-token-string-expression: theme('colors.light.4');
--shiki-token-punctuation: #acc6ee; --shiki-token-punctuation: #acc6ee;
--shiki-token-link: theme('colors.pomegranate.400'); --shiki-token-link: theme('colors.pomegranate.400');
--shiki-token-inserted: #85e89d;
--shiki-token-deleted: #fdaeb7;
--shiki-token-changed: #56b6c2;
} }
@@ -4,6 +4,7 @@ import { ContentTarget } from '@/lib/api';
import { ContentRefContext, ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references'; import { ContentRefContext, ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references';
import { ClassValue } from '@/lib/tailwind'; import { ClassValue } from '@/lib/tailwind';
import { BlockSkeleton } from './Block';
import { Blocks } from './Blocks'; import { Blocks } from './Blocks';
export interface DocumentContext { export interface DocumentContext {
@@ -46,6 +47,12 @@ export interface DocumentContext {
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a * https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/ */
shouldHighlightCode: (spaceId: string | undefined) => boolean; shouldHighlightCode: (spaceId: string | undefined) => boolean;
/**
* True if the blocks should be wrapped in suspense boundary for isolated loading skeletons.
* @default true
*/
wrapBlocksInSuspense?: boolean;
} }
export interface DocumentContextProps { export interface DocumentContextProps {
@@ -83,3 +90,28 @@ export function DocumentView(
/> />
); );
} }
/**
* Placeholder for the entire document layout.
*/
export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle: ClassValue }) {
const { document, blockStyle } = props;
return (
<div className="flex flex-col gap-4">
{document.nodes.map((block, index) => (
<BlockSkeleton
key={block.key!}
block={block}
style={[
'w-full mx-auto decoration-primary/6',
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
blockStyle,
]}
/>
))}
</div>
);
}
@@ -4,7 +4,7 @@ import Script from 'next/script';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives'; import { Card } from '@/components/primitives';
import { api } from '@/lib/api'; import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
@@ -17,9 +17,9 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce }); ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
const { data: embed } = await (context.content const embed = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url }) ? getEmbedByUrlInSpace(context.content.spaceId, block.data.url)
: api().urls.getEmbedByUrl({ url: block.data.url })); : getEmbedByUrl(block.data.url));
return ( return (
<Caption {...props}> <Caption {...props}>
@@ -0,0 +1,81 @@
'use client';
import React from 'react';
import { useHash } from '@/components/hooks';
import { ClassValue, tcls } from '@/lib/tailwind';
/**
* Details component rendered on client so it can expand dependent on url hash changes.
*/
export function Details(props: {
children: React.ReactNode;
id: string;
contentIds?: string[];
open?: boolean;
className?: ClassValue;
}) {
const { children, id, className } = props;
const detailsRef = React.useRef<HTMLDetailsElement>(null);
const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash();
/**
* Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element.
*/
React.useEffect(() => {
if (!hash || !detailsRef.current) {
return;
}
if (hash === id) {
setOpenFromHash(true);
}
const activeElement = document.getElementById(hash);
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
}, [hash, id]);
return (
<details
ref={detailsRef}
id={id}
open={props.open || openFromHash}
className={tcls(
className,
'group/expandable',
'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'border',
'border-b-0',
'border-dark-3/3',
//all
'[&]:mt-[0px]',
//select first child
'[&:first-child]:mt-5',
'[&:first-child]:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */
'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',
'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)}
>
{children}
</details>
);
}
@@ -1,10 +1,10 @@
import ChevronRight from '@geist-ui/icons/chevronRight';
import IconHash from '@geist-ui/icons/hash';
import { DocumentBlockExpandable } from '@gitbook/api'; import { DocumentBlockExpandable } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getNodeFragmentByType } from '@/lib/document'; import { getNodeFragmentByType } from '@/lib/document';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Details } from './Details';
import { BlockProps } from '../Block'; import { BlockProps } from '../Block';
import { Blocks } from '../Blocks'; import { Blocks } from '../Blocks';
import { Inlines } from '../Inlines'; import { Inlines } from '../Inlines';
@@ -25,42 +25,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id; id = context.getId ? context.getId(id) : id;
return ( return (
<details <Details id={id} open={context.mode === 'print'} className={style}>
id={id}
open={context.mode === 'print'}
className={tcls(
style,
'group/expandable',
'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'border',
'border-b-0',
'border-dark-3/3',
//all
'[&]:mt-[0px]',
//select first child
'[&:first-child]:mt-5',
'[&:first-child]:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */
'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',
'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)}
>
<summary <summary
className={tcls( className={tcls(
'cursor-pointer', 'cursor-pointer',
@@ -78,11 +43,11 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'[&::-webkit-details-marker]:hidden', '[&::-webkit-details-marker]:hidden',
)} )}
> >
<ChevronRight <Icon
icon="chevron-right"
className={tcls( className={tcls(
'inline-block', 'inline-block',
'w-4', 'size-3',
'h-4',
'mr-2', 'mr-2',
'mb-1', 'mb-1',
'transition-transform', 'transition-transform',
@@ -111,15 +76,15 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
'dark:ring-0', 'dark:ring-0',
)} )}
> >
<IconHash <Icon
icon="hashtag"
className={tcls( className={tcls(
'inline-block', 'inline-block',
'w-4', 'size-3',
'h-4',
'transition-colors', 'transition-colors',
'stroke-transparent', 'text-transparent',
'group-hover:stroke-dark/6', 'group-hover:text-dark/6',
'dark:group-hover:stroke-light/5', 'dark:group-hover:text-light/5',
)} )}
/> />
</a> </a>
@@ -129,8 +94,8 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
document={document} document={document}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
context={context} context={context}
style={['px-10', 'pb-5', 'space-y-3']} style={['px-10', 'pb-5', 'space-y-4']}
/> />
</details> </Details>
); );
} }
@@ -4,10 +4,11 @@ import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { Caption } from './Caption';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) { export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props; const { block, context } = props;
const contentRef = await context.resolveContentRef(block.data.ref); const contentRef = await context.resolveContentRef(block.data.ref);
const file = contentRef?.file; const file = contentRef?.file;
@@ -19,6 +20,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
const contentType = getSimplifiedContentType(file.contentType); const contentType = getSimplifiedContentType(file.contentType);
return ( return (
<Caption {...props} wrapperStyle={[]}>
<a <a
href={file.downloadURL} href={file.downloadURL}
download={file.name} download={file.name}
@@ -28,28 +30,13 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'flex-row', 'flex-row',
'items-center', 'items-center',
'border', 'border',
'border-b-0',
'px-5', 'px-5',
'py-3', 'py-3',
'border-dark/3', 'border-dark/3',
'rounded-lg',
//all
'[&]:mt-[0px]',
//select first child
'[&:first-child]:mt-5',
'[&:first-child]:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
'hover:text-primary-600', 'hover:text-primary-600',
'dark:border-light/3', 'dark:border-light/3',
'dark:hover:text-primary-300', 'dark:hover:text-primary-300',
style,
)} )}
> >
<div <div
@@ -66,8 +53,11 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'dark:border-light/2', 'dark:border-light/2',
)} )}
> >
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}> <div>
<FileIcon contentType={contentType} /> <FileIcon
contentType={contentType}
className={tcls('size-5', 'text-primary')}
/>
</div> </div>
<div <div
className={tcls( className={tcls(
@@ -83,9 +73,12 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
</div> </div>
<div> <div>
<div className={tcls('text-base')}>{file.name}</div> <div className={tcls('text-base')}>{file.name}</div>
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>{contentType}</div> <div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
{contentType}
</div>
</div> </div>
</a> </a>
</Caption>
); );
} }
@@ -1,24 +1,21 @@
import IconDownload from '@geist-ui/icons/download'; import { Icon } from '@gitbook/icons';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { SimplifiedFileType } from '@/lib/files'; import { SimplifiedFileType } from '@/lib/files';
/** /**
* Render an appropriate icon for a file. * Render an appropriate icon for a file.
*/ */
export function FileIcon(props: { contentType: SimplifiedFileType | null }) { export function FileIcon(props: { contentType: SimplifiedFileType | null; className: string }) {
const { contentType } = props; const { contentType, className } = props;
switch (contentType) { switch (contentType) {
case 'pdf': case 'pdf':
return <IconFileText />; return <Icon icon="file-pdf" className={className} />;
case 'image': case 'image':
return <IconImage />; return <Icon icon="file-image" className={className} />;
case 'archive': case 'archive':
return <IconPaperClip />; return <Icon icon="file-archive" className={className} />;
default: default:
return <IconDownload />; return <Icon icon="file-download" className={className} />;
} }
} }
@@ -1,5 +1,5 @@
import IconHash from '@geist-ui/icons/hash';
import { DocumentBlockHeading } from '@gitbook/api'; import { DocumentBlockHeading } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -48,14 +48,15 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
textStyle.lineHeight, textStyle.lineHeight,
)} )}
> >
<IconHash <Icon
icon="hashtag"
className={tcls( className={tcls(
'w-3.5', 'w-3.5',
'h-[1lh]', 'h-[1lh]',
'transition-colors', 'transition-colors',
'stroke-transparent', 'text-transparent',
'group-hover:stroke-dark/6', 'group-hover:text-dark/6',
'dark:group-hover:stroke-light/5', 'dark:group-hover:text-light/5',
'lg:w-4', 'lg:w-4',
)} )}
/> />
@@ -1,4 +1,5 @@
import { DocumentBlockHint } from '@gitbook/api'; import { DocumentBlockHint } from '@gitbook/api';
import { Icon, IconName } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { ClassValue, tcls } from '@/lib/tailwind'; import { ClassValue, tcls } from '@/lib/tailwind';
@@ -9,9 +10,8 @@ import { getBlockTextStyle } from './spacing';
export function Hint(props: BlockProps<DocumentBlockHint>) { export function Hint(props: BlockProps<DocumentBlockHint>) {
const { block, style, ancestorBlocks, ...contextProps } = props; const { block, style, ancestorBlocks, ...contextProps } = props;
const hintStyle = HINT_STYLES[block.data.style]; const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstLine = getBlockTextStyle(block.nodes[0]); const firstLine = getBlockTextStyle(block.nodes[0]);
const RenderIcon = HINT_STYLES[block.data.style].icon;
return ( return (
<div <div
@@ -34,17 +34,17 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
'pr-3', 'pr-3',
'mt-0.5', 'mt-0.5',
firstLine.lineHeight, firstLine.lineHeight,
hintStyle.anchorColor, hintStyle.iconColor,
)} )}
> >
<RenderIcon /> <Icon icon={hintStyle.icon} className={tcls('size-4')} />
</div> </div>
<Blocks <Blocks
{...contextProps} {...contextProps}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
nodes={block.nodes} nodes={block.nodes}
blockStyle={tcls( blockStyle={tcls(
hintStyle.anchorColor, hintStyle.bodyColor,
// render hash icon on the other side of the heading // render hash icon on the other side of the heading
'flip-heading-hash', 'flip-heading-hash',
)} )}
@@ -57,14 +57,16 @@ export function Hint(props: BlockProps<DocumentBlockHint>) {
const HINT_STYLES: { const HINT_STYLES: {
[style in DocumentBlockHint['data']['style']]: { [style in DocumentBlockHint['data']['style']]: {
icon: () => React.ReactNode; icon: IconName;
anchorColor: ClassValue; iconColor: ClassValue;
bodyColor: ClassValue;
style: ClassValue; style: ClassValue;
}; };
} = { } = {
info: { info: {
icon: InfoIcon, icon: 'circle-info',
anchorColor: [ iconColor: ['text-periwinkle-700', 'dark:text-periwinkle-400'],
bodyColor: [
'[&>a]:text-periwinkle-700', '[&>a]:text-periwinkle-700',
'[&>a:hover]:text-periwinkle-800', '[&>a:hover]:text-periwinkle-800',
'[&>code]:bg-periwinkle-700/4', '[&>code]:bg-periwinkle-700/4',
@@ -72,8 +74,6 @@ const HINT_STYLES: {
'[&>code]:shadow-none', '[&>code]:shadow-none',
'text-periwinkle-900', 'text-periwinkle-900',
'fill-periwinkle-700',
'stroke-periwinkle-700',
'dark:text-periwinkle-200', 'dark:text-periwinkle-200',
'dark:[&>a]:text-periwinkle', 'dark:[&>a]:text-periwinkle',
@@ -82,8 +82,6 @@ const HINT_STYLES: {
'dark:[&>code]:text-inherit', 'dark:[&>code]:text-inherit',
'decoration-periwinkle-700/6', 'decoration-periwinkle-700/6',
'dark:decoration-periwinkle/6', 'dark:decoration-periwinkle/6',
'dark:fill-periwinkle-400',
'dark:stroke-periwinkle-400',
], ],
style: [ style: [
'bg-gradient-to-b', 'bg-gradient-to-b',
@@ -98,8 +96,9 @@ const HINT_STYLES: {
], ],
}, },
warning: { warning: {
icon: WarningIcon, icon: 'circle-exclamation',
anchorColor: [ iconColor: ['text-yellow-800', 'dark:text-yellow'],
bodyColor: [
'[&>a]:text-yellow-700', '[&>a]:text-yellow-700',
'[&>a:hover]:text-yellow-800', '[&>a:hover]:text-yellow-800',
'[&>code]:bg-yellow-600/5', '[&>code]:bg-yellow-600/5',
@@ -108,7 +107,6 @@ const HINT_STYLES: {
'text-yellow-900', 'text-yellow-900',
'fill-yellow-800',
'dark:text-yellow-200', 'dark:text-yellow-200',
'dark:[&>a]:text-yellow', 'dark:[&>a]:text-yellow',
'dark:[&>a:hover]:text-yellow-600', 'dark:[&>a:hover]:text-yellow-600',
@@ -116,7 +114,6 @@ const HINT_STYLES: {
'dark:[&>code]:text-inherit', 'dark:[&>code]:text-inherit',
'decoration-yellow-700/6', 'decoration-yellow-700/6',
'dark:decoration-yellow/6', 'dark:decoration-yellow/6',
'dark:fill-yellow',
], ],
style: [ style: [
'bg-gradient-to-b', 'bg-gradient-to-b',
@@ -131,8 +128,9 @@ const HINT_STYLES: {
], ],
}, },
danger: { danger: {
icon: DangerIcon, icon: 'triangle-exclamation',
anchorColor: [ iconColor: ['text-pomegranate-700', 'dark:text-pomegranate-400'],
bodyColor: [
'[&>a]:text-pomegranate-600/9', '[&>a]:text-pomegranate-600/9',
'[&>a:hover]:text-pomegranate-800', '[&>a:hover]:text-pomegranate-800',
'[&>code]:bg-pomegranate-600/4', '[&>code]:bg-pomegranate-600/4',
@@ -141,7 +139,6 @@ const HINT_STYLES: {
'text-pomegranate-900', 'text-pomegranate-900',
'fill-pomegranate-700',
'dark:text-pomegranate-100', 'dark:text-pomegranate-100',
'dark:[&>a]:text-pomegranate', 'dark:[&>a]:text-pomegranate',
'dark:[&>a:hover]:text-pomegranate-600', 'dark:[&>a:hover]:text-pomegranate-600',
@@ -149,7 +146,6 @@ const HINT_STYLES: {
'dark:[&>code]:text-inherit', 'dark:[&>code]:text-inherit',
'decoration-pomegranate-600/6', 'decoration-pomegranate-600/6',
'dark:decoration-pomegranate/6', 'dark:decoration-pomegranate/6',
'dark:fill-pomegranate-400',
], ],
style: [ style: [
'bg-gradient-to-b', 'bg-gradient-to-b',
@@ -164,8 +160,9 @@ const HINT_STYLES: {
], ],
}, },
success: { success: {
icon: SuccessIcon, icon: 'circle-check',
anchorColor: [ iconColor: ['text-teal-700', 'dark:text-teal-400'],
bodyColor: [
'[&>a]:text-teal-600', '[&>a]:text-teal-600',
'[&>a:hover]:text-teal-800', '[&>a:hover]:text-teal-800',
'[&>code]:bg-teal-600/4', '[&>code]:bg-teal-600/4',
@@ -173,8 +170,6 @@ const HINT_STYLES: {
'[&>code]:shadow-none', '[&>code]:shadow-none',
'text-teal-800', 'text-teal-800',
'fill-teal-700',
'stroke-teal-700',
'dark:text-teal-100', 'dark:text-teal-100',
'dark:[&>a]:text-teal-400', 'dark:[&>a]:text-teal-400',
@@ -183,8 +178,6 @@ const HINT_STYLES: {
'dark:[&>code]:text-inherit', 'dark:[&>code]:text-inherit',
'decoration-teal/6', 'decoration-teal/6',
'dark:decoration-teal/6', 'dark:decoration-teal/6',
'dark:fill-teal-400',
'dark:stroke-teal-300',
], ],
style: [ style: [
'bg-gradient-to-b', 'bg-gradient-to-b',
@@ -199,131 +192,3 @@ const HINT_STYLES: {
], ],
}, },
}; };
function InfoIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
className={tcls('overflow-visible')}
>
<circle cx="8" cy="4.5" r="1.5" fill="inherit" fillOpacity="1" stroke="none" />
<circle
cx="8"
cy="4.5"
r="1.5"
fill="inherit"
fillOpacity="0.2"
stroke="none"
className={tcls(
'animate-[pingAlt_8s_ease_infinite_forwards]',
'origin-[8px_4.5px]',
'w-[4px]',
'h-[4px]',
)}
/>
<rect
x="7"
y="7"
width="2"
rx="1"
height="6"
fill="inherit"
fillOpacity="1"
stroke="none"
/>
<circle cx="8" cy="8" r="8" fill="inherit" fillOpacity="0.16" stroke="none" />
</svg>
);
}
function DangerIcon() {
return (
<svg width="18" height="16" viewBox="0 0 18 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.2546 1C8.03034 -0.333333 9.96967 -0.333333 10.7454 1L17.727 13C18.5027 14.3333 17.533 16 15.9816 16H2.01842C0.466953 16 -0.502711 14.3333 0.27302 13L7.2546 1Z"
fill="inherit"
fillOpacity="0.16"
stroke="none"
/>
<rect
x="8"
y="4"
width="2"
height="6"
rx="1"
fill="inherit"
fillOpacity="0.92"
stroke="none"
className={tcls(
'animate-[wag_4s_ease_infinite_forwards]',
'[animation-composition:accumulate]',
'origin-[9px_12.5px]',
'will-change-transform',
)}
/>
<circle cx="9" cy="12.5" r="1.5" fill="inherit" fillOpacity="0.92" stroke="none" />
</svg>
);
}
function WarningIcon() {
return (
<svg width="16" height="18" viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.9736 0.277489C7.60874 -0.0924963 8.39126 -0.0924962 9.0264 0.277489L14.9736 3.74189C15.6087 4.11187 16 4.79563 16 5.5356V12.4644C16 13.2044 15.6087 13.8881 14.9736 14.2581L9.0264 17.7225C8.39126 18.0925 7.60874 18.0925 6.9736 17.7225L1.0264 14.2581C0.391261 13.8881 0 13.2044 0 12.4644V5.5356C0 4.79563 0.391262 4.11187 1.0264 3.74189L6.9736 0.277489Z"
fill="inherit"
fillOpacity="0.16"
stroke="none"
/>
<rect
x="7"
y="4"
width="2"
height="6"
rx="1"
fill="inherit"
fillOpacity="0.8"
stroke="none"
/>
<circle
cx="8"
cy="12.5"
r="1.5"
fill="inherit"
fillOpacity="1"
stroke="none"
className={tcls('animate-[pulse_1s_ease_infinite_forwards]')}
/>
</svg>
);
}
function SuccessIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="inherit" fillOpacity="0.16" stroke="none" />
<path
className={tcls('animate-[stroke_8s_ease_infinite_forwards]')}
d="M4 8.5L6.5 11L12 5.5"
stroke="inherit"
fill="none"
strokeOpacity="0.8"
strokeWidth="2"
strokeLinecap="round"
pathLength="100"
/>
<path
d="M4 8.5L6.5 11L12 5.5"
stroke="inherit"
fill="none"
strokeOpacity="0.16"
strokeWidth="2"
strokeLinecap="round"
pathLength="100"
/>
</svg>
);
}
@@ -11,12 +11,10 @@ import { ClassValue, tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { Caption } from './Caption'; import { Caption } from './Caption';
import { DocumentContext } from './DocumentView'; import { DocumentContext } from './DocumentView';
import { isBlockOffscreen } from './utils';
export function Images(props: BlockProps<DocumentBlockImages>) { export function Images(props: BlockProps<DocumentBlockImages>) {
const { document, block, ancestorBlocks, style, context } = props; const { document, block, ancestorBlocks, style, context, isEstimatedOffscreen } = props;
const isOffscreen = isBlockOffscreen({ document, block, ancestorBlocks });
const isMultipleImages = block.nodes.length > 1; const isMultipleImages = block.nodes.length > 1;
const { align = 'center' } = block.data; const { align = 'center' } = block.data;
@@ -41,7 +39,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
style={[]} style={[]}
siblings={block.nodes.length} siblings={block.nodes.length}
context={context} context={context}
isOffscreen={isOffscreen} isEstimatedOffscreen={isEstimatedOffscreen}
/> />
))} ))}
</div> </div>
@@ -67,9 +65,9 @@ async function ImageBlock(props: {
style: ClassValue; style: ClassValue;
context: DocumentContext; context: DocumentContext;
siblings: number; siblings: number;
isOffscreen: boolean; isEstimatedOffscreen: boolean;
}) { }) {
const { block, context, isOffscreen } = props; const { block, context, isEstimatedOffscreen } = props;
const [src, darkSrc] = await Promise.all([ const [src, darkSrc] = await Promise.all([
context.resolveContentRef(block.data.ref), context.resolveContentRef(block.data.ref),
@@ -97,7 +95,7 @@ async function ImageBlock(props: {
} }
: null, : null,
}} }}
priority={isOffscreen ? 'lazy' : 'high'} priority={isEstimatedOffscreen ? 'lazy' : 'high'}
preload preload
zoom zoom
inlineStyle={{ inlineStyle={{
@@ -1,23 +1,9 @@
import {
Edit,
Github,
Gitlab,
Maximize,
X,
Mail,
Settings,
Search,
Delete,
Star,
AlertTriangle,
Link,
Eye,
Lock,
} from '@geist-ui/icons';
import { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api'; import { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { ContentKit, ContentKitOutput, ContentKitServerContext } from '@gitbook/react-contentkit'; import { ContentKit, ContentKitOutput, ContentKitServerContext } from '@gitbook/react-contentkit';
import { ignoreAPIError, renderIntegrationUi } from '@/lib/api'; import { ignoreAPIError, renderIntegrationUi } from '@/lib/api';
import { INTEGRATIONS_HOST } from '@/lib/csp';
import { parseMarkdown } from '@/lib/markdown'; import { parseMarkdown } from '@/lib/markdown';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -27,21 +13,21 @@ import { PlainCodeBlock } from '../CodeBlock';
const outputContext: ContentKitServerContext = { const outputContext: ContentKitServerContext = {
icons: { icons: {
maximize: Maximize, maximize: (props) => <Icon icon="maximize" {...props} />,
edit: Edit, edit: (props) => <Icon icon="edit" {...props} />,
github: Github, github: (props) => <Icon icon="github" {...props} />,
gitlab: Gitlab, gitlab: (props) => <Icon icon="gitlab" {...props} />,
close: X, close: (props) => <Icon icon="x" {...props} />,
email: Mail, email: (props) => <Icon icon="envelope" {...props} />,
settings: Settings, settings: (props) => <Icon icon="gear" {...props} />,
search: Search, search: (props) => <Icon icon="magnifying-glass" {...props} />,
delete: Delete, delete: (props) => <Icon icon="trash" {...props} />,
star: Star, star: (props) => <Icon icon="star" {...props} />,
warning: AlertTriangle, warning: (props) => <Icon icon="triangle-exclamation" {...props} />,
link: Link, link: (props) => <Icon icon="link" {...props} />,
'link-external': Link, 'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: Eye, eye: (props) => <Icon icon="eye" {...props} />,
lock: Lock, lock: (props) => <Icon icon="lock" {...props} />,
}, },
codeBlock: (props) => { codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />; return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
@@ -77,14 +63,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
renderIntegrationUi(block.data.integration, initialInput), renderIntegrationUi(block.data.integration, initialInput),
true, true,
); );
if (!initialOutput) { if (!initialOutput || initialOutput.type === 'complete') {
return null; return null;
} }
return ( return (
<div className={tcls(style)}> <div className={tcls(style)}>
<ContentKit <ContentKit
security={{ firstPartyDomains: ['integrations.gitbook.com'] }} security={{ firstPartyDomains: [INTEGRATIONS_HOST] }}
initialInput={initialInput} initialInput={initialInput}
initialOutput={initialOutput} initialOutput={initialOutput}
render={async (request) => { render={async (request) => {
@@ -7,7 +7,7 @@ import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { InlineProps } from './Inline'; import { InlineProps } from './Inline';
const mathJaxUrl = getStaticFileURL('mathjax@3.2.2/tex-chtml.js'); const assetsUrl = getStaticFileURL('math');
export async function BlockMath(props: BlockProps<DocumentBlockMath>) { export async function BlockMath(props: BlockProps<DocumentBlockMath>) {
const { block, style } = props; const { block, style } = props;
@@ -17,7 +17,7 @@ export async function BlockMath(props: BlockProps<DocumentBlockMath>) {
formula={block.data.formula} formula={block.data.formula}
inline={false} inline={false}
className={tcls(style, 'overflow-x-auto')} className={tcls(style, 'overflow-x-auto')}
mathJaxUrl={mathJaxUrl} assetsUrl={assetsUrl}
/> />
); );
} }
@@ -25,5 +25,5 @@ export async function BlockMath(props: BlockProps<DocumentBlockMath>) {
export async function InlineMath(props: InlineProps<DocumentInlineMath>) { export async function InlineMath(props: InlineProps<DocumentInlineMath>) {
const { inline } = props; const { inline } = props;
return <MathFormula formula={inline.data.formula} inline={true} mathJaxUrl={mathJaxUrl} />; return <MathFormula formula={inline.data.formula} inline={true} assetsUrl={assetsUrl} />;
} }
@@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight } from '@geist-ui/icons';
import { DocumentBlockSwagger } from '@gitbook/api'; import { DocumentBlockSwagger } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { OpenAPIOperation } from '@gitbook/react-openapi'; import { OpenAPIOperation } from '@gitbook/react-openapi';
import React from 'react'; import React from 'react';
@@ -20,11 +20,6 @@ export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
const { block, style } = props; const { block, style } = props;
return ( return (
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}> <div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
{/*
Invisible span with the ID to correctly identify the active section in the aside navigation.
We don't use the full <div> because it can be longer than the viewport and will not work well with IntersectionObserver.
*/}
<span id={block.meta?.id} />
<React.Suspense fallback={<OpenAPIFallback />}> <React.Suspense fallback={<OpenAPIFallback />}>
<OpenAPIBody {...props} /> <OpenAPIBody {...props} />
</React.Suspense> </React.Suspense>
@@ -34,7 +29,7 @@ export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) { async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
const { block, context } = props; const { block, context } = props;
const { data, error } = await fetchOpenAPIBlock(block, context.resolveContentRef); const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef);
if (error) { if (error) {
return ( return (
@@ -46,7 +41,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
); );
} }
if (!data) { if (!data || !specUrl) {
return null; return null;
} }
@@ -54,12 +49,15 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
<OpenAPIOperation <OpenAPIOperation
data={data} data={data}
context={{ context={{
specUrl,
icons: { icons: {
chevronDown: <ChevronDown />, chevronDown: <Icon icon="chevron-down" />,
chevronRight: <ChevronRight />, chevronRight: <Icon icon="chevron-right" />,
}, },
CodeBlock: PlainCodeBlock, CodeBlock: PlainCodeBlock,
defaultInteractiveOpened: context.mode === 'print', defaultInteractiveOpened: context.mode === 'print',
id: block.meta?.id,
blockKey: block.key,
}} }}
className="openapi-block" className="openapi-block"
/> />
@@ -1,4 +1,7 @@
@import '@scalar/api-client-react/style.css';
.light .scalar-modal-layout, .light .scalar-modal-layout,
.light .scalar-app,
.light .scalar { .light .scalar {
--scalar-color-1: color-mix( --scalar-color-1: color-mix(
in srgb, in srgb,
@@ -50,8 +53,13 @@
--scalar-button-1: rgb(49 53 56); --scalar-button-1: rgb(49 53 56);
--scalar-button-1-color: #fff; --scalar-button-1-color: #fff;
--scalar-button-1-hover: rgb(28 31 33); --scalar-button-1-hover: rgb(28 31 33);
--scalar-shadow-1: 0 1px 3px 0 rgba(0, 0, 0, 0.11);
--scalar-shadow-2: rgba(0, 0, 0, 0.08) 0px 13px 20px 0px, rgba(0, 0, 0, 0.08) 0px 3px 8px 0px,
#eeeeed 0px 0 0 1px;
} }
.dark .scalar-modal-layout, .dark .scalar-modal-layout,
.dark .scalar-app,
.dark .scalar { .dark .scalar {
--scalar-color-1: color-mix( --scalar-color-1: color-mix(
in srgb, in srgb,
@@ -102,8 +110,13 @@
--scalar-button-1: #f6f6f6; --scalar-button-1: #f6f6f6;
--scalar-button-1-color: #000; --scalar-button-1-color: #000;
--scalar-button-1-hover: #e7e7e7; --scalar-button-1-hover: #e7e7e7;
--scalar-shadow-1: 0 1px 3px 0 rgb(0, 0, 0, 0.1);
--scalar-shadow-2: rgba(15, 15, 15, 0.2) 0px 3px 6px, rgba(15, 15, 15, 0.4) 0px 9px 24px,
0 0 0 1px rgba(255, 255, 255, 0.1);
} }
.scalar-modal-layout, .scalar-modal-layout,
.scalar-app,
.scalar { .scalar {
--scalar-font: initial; --scalar-font: initial;
--scalar-font-code: var(--font-mono); --scalar-font-code: var(--font-mono);
@@ -165,7 +178,7 @@
.scalar-api-client__close:hover { .scalar-api-client__close:hover {
cursor: pointer; cursor: pointer;
} }
.scalar .scalar-app { .scalar .scalar-app-layout {
background: var(--scalar-background-3); background: var(--scalar-background-3);
height: calc(100dvh - 100px); height: calc(100dvh - 100px);
max-width: 1280px; max-width: 1280px;
@@ -202,23 +215,6 @@
cursor: pointer; cursor: pointer;
animation: scalardrawerexitfadein 0.35s forwards; animation: scalardrawerexitfadein 0.35s forwards;
} }
.scalar .scalar-app-exit:before {
content: '\00d7';
font-family: sans-serif;
position: absolute;
top: 0;
right: 0;
font-size: 30px;
font-weight: 100;
line-height: 50px;
right: 12px;
text-align: center;
color: white;
opacity: 0.6;
}
.scalar .scalar-app-exit:hover:before {
opacity: 1;
}
@keyframes scalardrawerexitfadein { @keyframes scalardrawerexitfadein {
from { from {
opacity: 0; opacity: 0;
@@ -305,11 +301,6 @@
scrollbar-width: thin; scrollbar-width: thin;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@supports (-moz-appearance: none) {
.scalar .custom-scroll {
padding-right: 12px;
}
}
.scalar .custom-scroll:hover { .scalar .custom-scroll:hover {
scrollbar-color: rgba(0, 0, 0, 0.24) transparent; scrollbar-color: rgba(0, 0, 0, 0.24) transparent;
} }
@@ -347,3 +338,18 @@
padding-right: 12px; padding-right: 12px;
} }
} }
.dark .scalar .client-wrapper-bg-color {
background: linear-gradient(
color-mix(in srgb, var(--tw-bg-base) 6%, transparent) 1%,
color-mix(in srgb, var(--scalar-background-1) 30%, black) 9%
);
}
.light .scalar .client-wrapper-bg-color {
background-color: var(--scalar-background-2) !important;
}
.scalar .gitbook-show {
display: block !important;
}
.scalar .gitbook-hidden {
display: none !important;
}
@@ -1,4 +1,6 @@
.openapi-operation { .openapi-operation {
content-visibility: auto;
contain-intrinsic-height: 600px;
@apply flex-1 flex flex-col gap-6; @apply flex-1 flex flex-col gap-6;
} }
@@ -172,7 +174,8 @@
@apply prose-sm; @apply prose-sm;
} }
.openapi-schema-example { .openapi-schema-example,
.openapi-schema-pattern {
@apply prose-sm mt-2 text-dark/10 dark:text-light/10; @apply prose-sm mt-2 text-dark/10 dark:text-light/10;
} }
@@ -346,7 +349,7 @@
} }
.openapi-section-toggle svg { .openapi-section-toggle svg {
@apply size-4; @apply size-3;
} }
/** Markdown */ /** Markdown */
@@ -21,6 +21,7 @@ export function Quote(props: BlockProps<DocumentBlockQuote>) {
'border-dark/2', 'border-dark/2',
'dark:text-light/7', 'dark:text-light/7',
'dark:border-light/2', 'dark:border-light/2',
'space-y-4',
]} ]}
/> />
); );
@@ -0,0 +1,34 @@
import { DocumentBlockReusableContent } from '@gitbook/api';
import { getDocument } from '@/lib/api';
import { BlockProps } from './Block';
import { UnwrappedBlocks } from './Blocks';
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
const { block, context, ancestorBlocks } = props;
if (!context.content) {
throw new Error(`Expected a content context to render a reusable content block`);
}
const resolved = await context.resolveContentRef(block.data.ref);
if (!resolved?.reusableContent?.document) {
return null;
}
const document = await getDocument(context.content.spaceId, resolved.reusableContent.document);
if (!document) {
return null;
}
return (
<UnwrappedBlocks
nodes={document.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
context={context}
/>
);
}
@@ -0,0 +1,12 @@
import { DocumentBlockStepper } from '@gitbook/api';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function Stepper(props: BlockProps<DocumentBlockStepper>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks {...contextProps} nodes={block.nodes} ancestorBlocks={[...ancestorBlocks, block]} />
);
}
@@ -0,0 +1,53 @@
import { DocumentBlockStepperStep } from '@gitbook/api';
import { assert } from 'ts-essentials';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
const ancestor = ancestorBlocks[ancestorBlocks.length - 1];
assert(ancestor.type === 'stepper', 'Ancestor block must be a stepper');
const index = ancestor.nodes.indexOf(block);
const firstChild = block.nodes[0];
const marginAdjustClassName = (() => {
if (!firstChild) {
return '';
}
switch (firstChild.type) {
case 'heading-1':
return '-mt-9';
case 'heading-2':
return '-mt-[calc(1.25rem+1px)]';
default:
return '';
}
})();
return (
<div className="flex flex-row gap-4 md:gap-8 max-w-3xl w-full mx-auto">
<div className="relative select-none">
<div
className={tcls(
'flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900 tabular-nums',
'font-medium text-primary-800 dark:text-primary-200',
)}
>
{index + 1}
</div>
<div className="absolute bottom-2 left-[0.875rem] top-9 w-px bg-primary-50 dark:bg-primary-900" />
</div>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={['flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
/>
</div>
);
}
@@ -1,4 +1,5 @@
import { ContentRef, DocumentTableViewCards } from '@gitbook/api'; import { ContentRef, DocumentTableViewCards } from '@gitbook/api';
import React from 'react';
import { Image } from '@/components/utils'; import { Image } from '@/components/utils';
import { ClassValue, tcls } from '@/lib/tailwind'; import { ClassValue, tcls } from '@/lib/tailwind';
@@ -12,7 +13,7 @@ export async function RecordCard(
record: TableRecordKV; record: TableRecordKV;
}, },
) { ) {
const { view, record, context, isOffscreen } = props; const { view, record, context, block, isOffscreen } = props;
const coverFile = view.coverDefinition const coverFile = view.coverDefinition
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0] ? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
@@ -99,6 +100,31 @@ export async function RecordCard(
)} )}
> >
{view.columns.map((column) => { {view.columns.map((column) => {
const definition = block.data.definition[column];
if (!definition) {
return null;
}
if (!view.hideColumnTitle && definition.title) {
const ariaLabelledBy = `${block.key}-${column}-title`;
return (
<div key={column} className="flex flex-col gap-1">
<div
id={ariaLabelledBy}
className="text-sm text-dark/8 dark:text-light/8"
>
{definition.title}
</div>
<RecordColumnValue
{...props}
column={column}
ariaLabelledBy={ariaLabelledBy}
/>
</div>
);
}
return <RecordColumnValue key={column} {...props} column={column} />; return <RecordColumnValue key={column} {...props} column={column} />;
})} })}
</div> </div>

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