mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-26 04:07:07 +00:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ee1a7ac4 | |||
| a7af3ca7bc | |||
| cda08a9843 | |||
| 7588774237 | |||
| 683373b263 | |||
| 673ec8fb5d | |||
| 63bacaa671 | |||
| e3a3d6a505 | |||
| 175171dda1 | |||
| a4b63b88c8 | |||
| c3675fdfe7 | |||
| 846de0e1a9 | |||
| 2fa08519b6 | |||
| 065627060b | |||
| 2982b6a808 | |||
| c3f56ecd40 | |||
| fe9e6c1afd | |||
| 73394ae929 | |||
| c754fc9ff4 | |||
| 1ca6208057 | |||
| cdfae4d4df | |||
| 3d9aefaf25 | |||
| c3da5469ca | |||
| 79f2d8ba76 | |||
| 7b1bdb92a6 | |||
| f9a075eed1 | |||
| 32e4193557 | |||
| 5beb2dbd69 | |||
| d19e3507cc | |||
| 152aad2c8f | |||
| 3ee149d87d | |||
| 0ccc5034c4 | |||
| 95e8772eab | |||
| 9675290e61 | |||
| c7ca921d7f | |||
| 2d4ddc9d95 | |||
| 65d77295e5 | |||
| 662df6f9aa | |||
| e866080867 | |||
| dce39c4c0d | |||
| 7b02f72660 | |||
| 74392eccee | |||
| 184d616701 | |||
| 9b78692124 | |||
| f67b143851 | |||
| f65e3f5fbb | |||
| ac492e094d | |||
| f6823e92bb | |||
| 9437aa25e8 | |||
| 77d64d06b9 | |||
| 1ae1715993 | |||
| c1193fd2cd | |||
| 133c3e7c0a | |||
| 6ce3cea682 | |||
| 60a9b95a25 | |||
| 250e77d18b | |||
| 586e66d03a | |||
| 39d88867ae | |||
| f98b7b2b00 | |||
| abf7ec35a6 | |||
| cb3c1f9f78 | |||
| ce890c609c | |||
| e3bfe006e9 | |||
| 5b933777e9 | |||
| 76aeecdc68 | |||
| 15d2ee3e76 | |||
| b066db479f | |||
| a69ff2789a | |||
| 4df235d64a | |||
| 11524457e3 | |||
| 8f7364293e | |||
| 56f5fa1fbe | |||
| b075f0f7e9 | |||
| 0ca32afe8d | |||
| 32c2f257a9 | |||
| a7066ccbe1 | |||
| 09248e0efe | |||
| e2ef22fc5f | |||
| 75f825e4f8 | |||
| 9b8d5198e4 | |||
| e09f747727 | |||
| 16e6171750 | |||
| f885e887ea | |||
| 5096f7fbc5 | |||
| 8d1c349ed1 | |||
| 4026fc1339 | |||
| f16560c1ee | |||
| 4b9f206e50 | |||
| d0f4860759 | |||
| f6b75e7dbd | |||
| 949f25cfa6 | |||
| 0b44528a1c | |||
| 34d36c6de1 | |||
| ea4f70ec88 | |||
| 768b555f66 | |||
| 96d5537087 | |||
| 28c59d81e7 | |||
| 3d147902d6 | |||
| a6adfaaf27 | |||
| d29a7a6f15 | |||
| 7244a0419a | |||
| c338bca2ba | |||
| 4f59d18bfc | |||
| b87ec84fd5 | |||
| e793a177f6 | |||
| 042b850b5d | |||
| 9573535eba | |||
| 087a783574 | |||
| 3b0f5d82d5 | |||
| 69414bdc52 | |||
| adaf0deb5b | |||
| d919d2ccfa | |||
| 2d15453e51 | |||
| ca4518d068 | |||
| cb454a3c4e | |||
| e852206573 | |||
| 7e79e14a62 | |||
| 4d476a8cbf | |||
| 73892f4fc2 | |||
| 636b868bf1 | |||
| b144368e3f | |||
| 683b4193d1 | |||
| f4c953665a | |||
| 0f990c772a | |||
| 0bf985af61 | |||
| 42473611d3 | |||
| 2c437f7d94 | |||
| aa8c49eb0c | |||
| b32e40c72a | |||
| 2ff7ed1b52 | |||
| bfbed1a69b | |||
| ef9d012be9 | |||
| f12a2155c0 | |||
| 43f696c4a8 | |||
| 4c19014dcd | |||
| 0e7f9d6778 | |||
| 00a0bf3c1b | |||
| e9149036f6 | |||
| b6a9967959 | |||
| 8bbc3caf3e | |||
| 237b703a6c | |||
| cf3045a718 | |||
| a679e7283f | |||
| 13c75341e2 | |||
| 4cbcc5bb20 | |||
| 51955dae3b | |||
| 51fa3ab2c8 | |||
| af9e66e87e | |||
| 8878dc7f80 | |||
| 4336bc20e0 | |||
| 4a7613a521 | |||
| 689f55340f | |||
| 160ae3d49d |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': minor
|
||||
---
|
||||
|
||||
Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix scroll position when navigating pages on mobile
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add Python support in Code Samples
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add automatic color contrast in site header, restyle search button
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use ellipsis and fix icon color for more links in the header on small screen
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve consistency of change request preview by removing cache-control on response
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/icons': patch
|
||||
---
|
||||
|
||||
Update version for static assets to fix invalid caching
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Revalidate change request cached content when pressing refresh button
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/icons': patch
|
||||
---
|
||||
|
||||
Fix first run of the package in development mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add navigation tabs for sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve performances of loading pages with embeds by caching them
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Support resolution of new site URLs with sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': minor
|
||||
---
|
||||
|
||||
First version of a new cache backend powered by Cloudflare Durable Objects
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for Norwegian language
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Only display spaces dropdown in compact header when site is multi-variants
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': patch
|
||||
---
|
||||
|
||||
Enable Workers observability with a sampling of 0.1
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Allow selection of server url
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Automatically scroll to active item in TOC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for searching results in a sections site
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Persist state of tabs and dynamically sync them based on title
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Update ogimage with new design
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Show the caption for file blocks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Include offset in calculations of whether scrollable element is in view
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add required query parameters to the code sample
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Display pattern if available in parmas in OpenAPI block
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix inconsistent click area in table because of scroll indicator
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Stop using KV cache backend for now, but also improves it for higher performances
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Synchronize response and response example tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Show definition title when visible in cards
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix flickering when displaying an "Ask" answer with code blocks
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Rollback of scalar modal while fixing perf issue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Update design of Checkbox to be more consistent and readable
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix crash when `example` is undefined for a response
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Improve the toolbar for change-requests and revisions to show more actions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Added support for new Reusable Content block.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Adds tabs to Response Example section e.g. for status code examples
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Render mandatory headers in code sample
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Prevent search indexation for pages where it's configured as disabled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix table of contents being visible on mobile when disabled at the page level
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improving the look and feel of new section tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Don't show hidden pages in the empty state of a page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix linking to a tab itself
|
||||
+21
-7
@@ -1,6 +1,8 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### 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
|
||||
|
||||
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
|
||||
|
||||
@@ -71,4 +85,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -93,6 +93,12 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: packages/gitbook/test-results/
|
||||
retention-days: 3
|
||||
pagespeed-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing
|
||||
|
||||
@@ -35,3 +35,26 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_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 }}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<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="/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>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<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">
|
||||
<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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
3. Start your local development server.
|
||||
4. Start your local development server.
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
@@ -71,7 +80,7 @@ All pull-requests will be tested against both visual and performances testing to
|
||||
|
||||
## 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
|
||||
|
||||
@@ -79,7 +88,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -90,7 +99,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
||||
> [!WARNING]
|
||||
> 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.
|
||||
>
|
||||
@@ -110,6 +119,26 @@ If you plan to distribute the code, you must make the source code public to comp
|
||||
|
||||
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
|
||||
[](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
|
||||
|
||||
GitBook wouldn't be possible without these projects:
|
||||
|
||||
+13
-11
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"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",
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
@@ -16,17 +24,11 @@
|
||||
"e2e": "turbo run e2e",
|
||||
"changeset": "changeset",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local"
|
||||
"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": [
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.0.9"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.6": "patches/@vercel%2Fnext@4.3.6.patch"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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.0.0",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"lru_map": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.78.7"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CacheObjectStub';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||
|
||||
export * from './CacheObject';
|
||||
|
||||
export default class Worker extends WorkerEntrypoint {
|
||||
fetch() {
|
||||
return new Response('Hello, world!');
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -9,6 +9,7 @@
|
||||
"emoji-assets": "^8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./build.ts"
|
||||
"generate": "bun ./build.ts",
|
||||
"clean": "rm -rf ./dist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,6 @@
|
||||
|
||||
### Sentry ###
|
||||
# SENTRY_DSN=xxx
|
||||
|
||||
### Silent logs
|
||||
# SILENT=true
|
||||
@@ -32,4 +32,5 @@ screenshots/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated public files
|
||||
/public/~gitbook/static/
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exclude": ["/~gitbook/static/*"]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import type { CacheObject } from '@gitbook/cache-do';
|
||||
|
||||
declare global {
|
||||
interface CloudflareEnv {
|
||||
CACHE?: DurableObjectNamespace<CacheObject>;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationHeaderPreset,
|
||||
@@ -9,15 +8,17 @@ import {
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rison from 'rison';
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
|
||||
interface Test {
|
||||
name: string;
|
||||
url: string;
|
||||
run?: (page: Page) => Promise<unknown>;
|
||||
fullPage?: boolean;
|
||||
screenshot?: false;
|
||||
url: string; // URL to visit for testing
|
||||
run?: (page: Page) => Promise<unknown>; // The test to run
|
||||
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
||||
screenshot?: false; // Should a screenshot be stored
|
||||
only?: boolean; // Only run this test
|
||||
}
|
||||
|
||||
interface TestsCase {
|
||||
@@ -104,7 +105,32 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -131,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
|
||||
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)',
|
||||
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets',
|
||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page.waitForSelector(
|
||||
@@ -154,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
|
||||
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',
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -189,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
|
||||
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',
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -232,7 +258,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Versioning',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Revision',
|
||||
@@ -243,7 +269,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'PDF',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'PDF',
|
||||
@@ -253,7 +279,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -346,6 +372,11 @@ const testCases: TestsCase[] = [
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
},
|
||||
{
|
||||
name: 'Files',
|
||||
url: 'blocks/files',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
@@ -364,11 +395,15 @@ const testCases: TestsCase[] = [
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Page options',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Hidden',
|
||||
@@ -399,7 +434,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Customization',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without header',
|
||||
@@ -422,11 +457,34 @@ const testCases: TestsCase[] = [
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With header buttons',
|
||||
url: getCustomizationURL({
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
links: [
|
||||
{
|
||||
title: 'Secondary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
// @ts-ignore Remove once we upgrade to the latest version of the API
|
||||
style: 'button-secondary',
|
||||
},
|
||||
{
|
||||
title: 'Primary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
// @ts-ignore Remove once we upgrade to the latest version of the API
|
||||
style: 'button-primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without previewed ads',
|
||||
@@ -441,7 +499,7 @@ const testCases: TestsCase[] = [
|
||||
tests: [
|
||||
{
|
||||
name: 'Valid link',
|
||||
url: 'TGs8PkF4GWVtbmPnWhYL/',
|
||||
url: 'thDznyWXCeEoT55WB7HC/',
|
||||
},
|
||||
{
|
||||
name: 'Invalid link',
|
||||
@@ -528,7 +586,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacea?jwt_token=${token}`;
|
||||
return `spacea?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -545,7 +603,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spaceb?jwt_token=${token}`;
|
||||
return `spaceb?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -562,7 +620,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacec?jwt_token=${token}`;
|
||||
return `spacec?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -644,7 +702,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Languages',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: allLocales.map((locale) => ({
|
||||
name: locale,
|
||||
url: getCustomizationURL({
|
||||
@@ -658,12 +716,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) {
|
||||
test.describe(testCase.name, () => {
|
||||
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 url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||
await page.goto(url);
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
"e2e": "playwright test",
|
||||
"unit": "bun test {src,packages}",
|
||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons"
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.60.0",
|
||||
"@gitbook/api": "^0.72.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
@@ -39,13 +41,14 @@
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "^14.2.5",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.17.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.0",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"postcss-color-contrast": "^1.1.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
@@ -66,7 +69,7 @@
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^2.0.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@cloudflare/next-on-pages": "^1.12.1",
|
||||
"@cloudflare/next-on-pages": "^1.13.3",
|
||||
"@cloudflare/workers-types": "^4.20240725.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
|
||||
@@ -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({
|
||||
success: true,
|
||||
keys: result.keys,
|
||||
stats: result.stats,
|
||||
});
|
||||
}
|
||||
|
||||
+3
-3
@@ -3,15 +3,15 @@
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollToHash } from '@/components/hooks';
|
||||
import { useScrollPage } from '@/components/hooks';
|
||||
|
||||
/**
|
||||
* Client component to initialize interactivity for a page.
|
||||
*/
|
||||
export function PageClientLayout(props: {}) {
|
||||
// We use this hook in the page layout to ensure the elements for the blocks
|
||||
// are rendered before we scroll to the hash.
|
||||
useScrollToHash();
|
||||
// are rendered before we scroll to a hash or to the top of the page
|
||||
useScrollPage();
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
return null;
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default function PageSkeleton() {
|
||||
'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')} />
|
||||
<SkeletonParagraph style={tcls('mb-4')} />
|
||||
</div>
|
||||
+4
-7
@@ -1,14 +1,11 @@
|
||||
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 { getContentPointer } from '../../fetch';
|
||||
|
||||
export default async function NotFound() {
|
||||
const pointer = getContentPointer();
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getCurrentSiteLayoutData(pointer)
|
||||
: getSpaceLayoutData(pointer.spaceId));
|
||||
const pointer = getSiteContentPointer();
|
||||
const { customization } = await getSiteLayoutData(pointer);
|
||||
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
+32
-19
@@ -3,11 +3,13 @@ import { Metadata, Viewport } from 'next';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { serverUrlCache } from '@/components/DocumentView/OpenAPI/ServerUrlCache';
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
@@ -25,11 +27,14 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
|
||||
serverUrlCache.parse(searchParams);
|
||||
|
||||
const {
|
||||
content: contentPointer,
|
||||
contentTarget,
|
||||
sections,
|
||||
space,
|
||||
parent,
|
||||
site,
|
||||
customization,
|
||||
pages,
|
||||
page,
|
||||
@@ -64,22 +69,39 @@ export default async function Page(props: {
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
siteContext: 'siteId' in contentPointer ? contentPointer : null,
|
||||
siteContext: contentPointer,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages,
|
||||
page,
|
||||
};
|
||||
|
||||
const withSections = Boolean(sections && sections.length > 0);
|
||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||
|
||||
return (
|
||||
<>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
||||
) : 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
|
||||
space={space}
|
||||
contentPointer={contentPointer}
|
||||
pointer={contentPointer}
|
||||
contentTarget={contentTarget}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
@@ -90,19 +112,6 @@ export default async function Page(props: {
|
||||
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>
|
||||
<React.Suspense fallback={null}>
|
||||
<PageClientLayout />
|
||||
@@ -129,7 +138,7 @@ export async function generateMetadata({
|
||||
params: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
}): Promise<Metadata> {
|
||||
const { space, pages, page, customization, parent } = await getPageDataWithFallback({
|
||||
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
|
||||
pagePathParams: params,
|
||||
searchParams,
|
||||
});
|
||||
@@ -139,7 +148,7 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
return {
|
||||
title: [page.title, getContentTitle(space, customization, parent)]
|
||||
title: [page.title, getContentTitle(space, customization, site ?? null)]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
description: page.description ?? '',
|
||||
@@ -152,6 +161,10 @@ export async function generateMetadata({
|
||||
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
|
||||
],
|
||||
},
|
||||
robots:
|
||||
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
|
||||
? 'index, follow'
|
||||
: 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
+11
-9
@@ -13,12 +13,12 @@ import { assetsDomain } from '@/lib/assets';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||
import { absoluteHref, baseUrl } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
import { fetchSpaceData } from '../fetch';
|
||||
import { fetchContentData } from '../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -35,11 +35,12 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
contentTarget,
|
||||
customization,
|
||||
pages,
|
||||
parent,
|
||||
site,
|
||||
spaces,
|
||||
ancestors,
|
||||
scripts,
|
||||
} = await fetchSpaceData();
|
||||
sections,
|
||||
} = await fetchContentData();
|
||||
|
||||
ReactDOM.preconnect(api().endpoint);
|
||||
if (assetsDomain) {
|
||||
@@ -64,8 +65,9 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
<SpaceLayout
|
||||
space={space}
|
||||
contentTarget={contentTarget}
|
||||
parent={parent}
|
||||
site={site}
|
||||
spaces={spaces}
|
||||
sections={sections}
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
@@ -97,7 +99,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const { customization } = await fetchSpaceData();
|
||||
const { customization } = await fetchContentData();
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -108,11 +110,11 @@ export async function generateViewport(): Promise<Viewport> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
title: getContentTitle(space, customization, parent),
|
||||
title: getContentTitle(space, customization, site),
|
||||
generator: `GitBook (${buildVersion()})`,
|
||||
metadataBase: new URL(baseUrl()),
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
+8
-15
@@ -3,9 +3,8 @@ import { NextRequest } from 'next/server';
|
||||
|
||||
import { getCollection, getSite, getSpace } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -13,22 +12,16 @@ export const runtime = 'edge';
|
||||
* Generate a robots.txt for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getContentPointer();
|
||||
const space = await getSpace(
|
||||
pointer.spaceId,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const pointer = getSiteContentPointer();
|
||||
const [site, space] = await Promise.all([
|
||||
getSite(pointer.organizationId, pointer.siteId),
|
||||
getSpace(pointer.spaceId, pointer.siteShareKey),
|
||||
]);
|
||||
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...(shouldIndexSpace({ space, parent })
|
||||
...(isSpaceIndexable({ space, site })
|
||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
+17
-12
@@ -5,8 +5,8 @@ import { NextRequest } from 'next/server';
|
||||
import { getSpaceContentData } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -14,12 +14,10 @@ export const runtime = 'edge';
|
||||
* Generate a sitemap.xml for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(
|
||||
pointer,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden);
|
||||
const pointer = getSiteContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
||||
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
const urls = pages.map(({ page, depth }) => {
|
||||
// Decay priority with depth
|
||||
const priority = Math.pow(2, -0.25 * depth);
|
||||
@@ -72,19 +70,26 @@ type FlatPageEntry = { page: RevisionPageDocument; depth: number };
|
||||
|
||||
function flattenPages(
|
||||
rootPags: RevisionPage[],
|
||||
filter: (page: RevisionPageDocument) => boolean,
|
||||
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
|
||||
): FlatPageEntry[] {
|
||||
const flattenPage = (
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
depth: number,
|
||||
): FlatPageEntry[] => {
|
||||
const allowed = filter(page);
|
||||
if (!allowed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...(page.type === 'document' && filter(page) ? [{ page, depth }] : []),
|
||||
...(page.type === 'document' ? [{ page, depth }] : []),
|
||||
...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) : [],
|
||||
);
|
||||
}
|
||||
+7
-19
@@ -1,18 +1,11 @@
|
||||
import { ContentVisibility } from '@gitbook/api';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { getContentPointer } from '@/app/(space)/fetch';
|
||||
import {
|
||||
getCollection,
|
||||
getCurrentSiteCustomization,
|
||||
getSite,
|
||||
getSpace,
|
||||
getSpaceCustomization,
|
||||
} from '@/lib/api';
|
||||
import { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
@@ -42,20 +35,15 @@ export async function GET(req: NextRequest) {
|
||||
const options = getOptions(req.url);
|
||||
const size = SIZES[options.size];
|
||||
|
||||
const pointer = getContentPointer();
|
||||
const pointer = getSiteContentPointer();
|
||||
const spaceId = pointer.spaceId;
|
||||
|
||||
const [space, customization] = await Promise.all([
|
||||
getSpace(spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
|
||||
getSpace(spaceId, pointer.siteShareKey),
|
||||
getCurrentSiteCustomization(pointer),
|
||||
]);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const contentTitle = getContentTitle(space, customization, parent);
|
||||
const site = await getSite(pointer.organizationId, pointer.siteId);
|
||||
const contentTitle = getContentTitle(space, customization, site);
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,230 +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 siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
|
||||
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
|
||||
'siteId' in content
|
||||
? [
|
||||
getCurrentSiteData(content),
|
||||
fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey,
|
||||
}),
|
||||
]
|
||||
: [getSpaceData(content, siteShareKey)],
|
||||
);
|
||||
|
||||
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 siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
|
||||
? getCurrentSiteData(content)
|
||||
: getSpaceData(content, siteShareKey));
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+14
-20
@@ -11,7 +11,6 @@ import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getContentPointer } from '@/app/(space)/fetch';
|
||||
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
|
||||
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
||||
import { PolymorphicComponentProp } from '@/components/utils/types';
|
||||
@@ -32,6 +31,7 @@ import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
|
||||
|
||||
import './pdf.css';
|
||||
import { PageControlButtons } from './PageControlButtons';
|
||||
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
import { PrintButton } from './PrintButton';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
@@ -39,15 +39,12 @@ const DEFAULT_LIMIT = 100;
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const contentPointer = getContentPointer();
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
const [space, customization] = await Promise.all([
|
||||
getSpace(
|
||||
contentPointer.spaceId,
|
||||
'siteId' in contentPointer ? contentPointer.siteShareKey : undefined,
|
||||
),
|
||||
'siteId' in contentPointer
|
||||
? getCurrentSiteCustomization(contentPointer)
|
||||
: getSpaceCustomization(contentPointer.spaceId),
|
||||
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer
|
||||
? getCurrentSiteCustomization(pointer)
|
||||
: getSpaceCustomization(pointer.spaceId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -60,7 +57,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
* Render a space as a standalone HTML page that can be printed as a PDF.
|
||||
*/
|
||||
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
|
||||
const contentPointer = getContentPointer();
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
|
||||
const searchParams = new URLSearchParams(props.searchParams);
|
||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||
@@ -71,13 +68,10 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
||||
|
||||
// Load the content,
|
||||
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
|
||||
'siteId' in contentPointer
|
||||
? getCurrentSiteCustomization(contentPointer)
|
||||
: getSpaceCustomization(contentPointer.spaceId),
|
||||
getSpaceContentData(
|
||||
contentPointer,
|
||||
'siteId' in contentPointer ? contentPointer.siteShareKey : undefined,
|
||||
),
|
||||
'siteId' in pointer
|
||||
? getCurrentSiteCustomization(pointer)
|
||||
: getSpaceCustomization(pointer.spaceId),
|
||||
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
]);
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -175,7 +169,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
||||
space={space}
|
||||
page={page}
|
||||
refContext={{
|
||||
siteContext: 'siteId' in contentPointer ? contentPointer : null,
|
||||
siteContext: 'siteId' in pointer ? pointer : null,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages: rootPages,
|
||||
@@ -321,7 +315,7 @@ function selectPages(
|
||||
return [
|
||||
{ page, depth },
|
||||
...page.pages.flatMap((child) => {
|
||||
if (child.type === 'link') {
|
||||
if (child.type !== 'document') {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -356,7 +350,7 @@ function selectPages(
|
||||
}
|
||||
|
||||
const allPages = rootPages.flatMap((page) => {
|
||||
if (page.type === 'link') {
|
||||
if (page.type !== 'document' && page.type !== 'group') {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentPointer, getChangeRequest, getRevision } from '@/lib/api';
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
import { DateRelative } from '../primitives';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
content: ContentPointer;
|
||||
content: SiteContentPointer;
|
||||
space: Space;
|
||||
}
|
||||
|
||||
@@ -67,9 +72,30 @@ async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: s
|
||||
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
|
||||
|
||||
return (
|
||||
<ToolbarButton href={changeRequest.urls.app}>
|
||||
Change request #{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</ToolbarButton>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="code-branch" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</p>
|
||||
<p className="text-xs text-light/8 dark:text-light/8">
|
||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||
</p>
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<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 revision = await getRevision(spaceId, revisionId, {
|
||||
metadata: false,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<ToolbarButton href={revision.urls.app}>
|
||||
Revision created on {new Date(revision.createdAt).toLocaleDateString()}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton(props: { href: string; children: React.ReactNode }) {
|
||||
const { href, children } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={tcls(
|
||||
'block',
|
||||
'text-sm',
|
||||
'px-4',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'hover:bg-dark-1',
|
||||
'hover:text-white',
|
||||
'truncate',
|
||||
'text-light',
|
||||
'dark:text-light',
|
||||
'dark:hover:bg-dark-2',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="code-commit" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
Revision created <DateRelative value={revision.createdAt} />
|
||||
</p>
|
||||
{revision.git ? (
|
||||
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
|
||||
) : null}
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
{revision.git?.url ? (
|
||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||
<Icon
|
||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||
className="size-4"
|
||||
/>
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user