mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
388 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b29a885bae | |||
| 919da7f2c9 | |||
| d222c116e0 | |||
| df840dac18 | |||
| f8d4c7697c | |||
| dddb4ecbb8 | |||
| 470d10cb47 | |||
| dae11e9607 | |||
| 3d1eb53708 | |||
| a76c9a56a7 | |||
| eaad0d4e09 | |||
| 46edde9da6 | |||
| dda0cc635e | |||
| 727bde2d16 | |||
| 9f30af1b33 | |||
| ff05e20537 | |||
| 53de5b13c1 | |||
| a025118f69 | |||
| 71688a817b | |||
| 00c476ea7f | |||
| 5576906914 | |||
| d5aaccd2c4 | |||
| 7059c2ba25 | |||
| aaab157d49 | |||
| 9943276c0c | |||
| c31cf5243a | |||
| 0c03676efb | |||
| 883d10acf8 | |||
| 3e5e458118 | |||
| 0510b6f29e | |||
| 92b7668bf3 | |||
| 3973b1cbf3 | |||
| 160fca1ea1 | |||
| e90c96f7e9 | |||
| d935fb1b2e | |||
| a6529584a5 | |||
| 18953b2d6b | |||
| 21cbd9e3b0 | |||
| 38061bdf65 | |||
| d9029c7e1e | |||
| fe8acc986e | |||
| e721f17f7e | |||
| 2f73db785e | |||
| 9e18ae6587 | |||
| 9eca010f94 | |||
| 6e54a06d9c | |||
| 1f8e416e41 | |||
| cfccc44406 | |||
| 45b30c9c27 | |||
| 67f78368fb | |||
| d9c8d57e8e | |||
| 0d615e3888 | |||
| 68287d3df4 | |||
| dff08ae3e3 | |||
| 8cfa67c1dd | |||
| b41d425993 | |||
| 12c7862250 | |||
| 6f54826296 | |||
| ccf2cffc4b | |||
| f7b801b4c8 | |||
| 95f2aa45eb | |||
| 9524ff5f5a | |||
| 1823101b03 | |||
| 1a8cfd2a8b | |||
| 5b4e710770 | |||
| 1762f85ef9 | |||
| d370a3f0e3 | |||
| d3e573c2e9 | |||
| 94876e3ad1 | |||
| ad190602c7 | |||
| 162b4b78b6 | |||
| e5dc05e994 | |||
| b46cff70c0 | |||
| b6c3870eda | |||
| 1c1f504601 | |||
| 1b8a45678c | |||
| 38de9eb9d7 | |||
| eac1314a7d | |||
| 29d59793dc | |||
| 128ad20280 | |||
| 994ebff8c1 | |||
| 142938401c | |||
| 65cc4afb23 | |||
| e000f23c44 | |||
| 1de9d1aa33 | |||
| 0e601e277b | |||
| e582a47f37 | |||
| 1b251a78b0 | |||
| 24f5249ae9 | |||
| 17c1631013 | |||
| cb100d5e00 | |||
| 1fcc807513 | |||
| 5664e5a30f | |||
| 26e64019d8 | |||
| 56331d2e74 | |||
| 55b095e503 | |||
| 6a073e1dd5 | |||
| 960e25a3f6 | |||
| 1de338c312 | |||
| dbba50cea4 | |||
| 8f046a9cd6 | |||
| 2906e6038a | |||
| 87b8ea8c05 | |||
| 47971dce31 | |||
| 528a05398c | |||
| f584148ab9 | |||
| 0de22f7a96 | |||
| 09c7c30779 | |||
| fde32e26bf | |||
| a6f65916f1 | |||
| 6059efe094 | |||
| 16194c50ba | |||
| af3c6a971c | |||
| ecfdb976a3 | |||
| 08acea651a | |||
| 6b503608cd | |||
| d8763997a5 | |||
| 32aa1f99c1 | |||
| 718a8a54b1 | |||
| 741dd490d4 | |||
| 02d876e6d8 | |||
| 56c52e0bb0 | |||
| 6036becf93 | |||
| 3a7210d514 | |||
| 6088fa582d | |||
| 6691492ef7 | |||
| 7ee9158cd3 | |||
| 5ae1b883ab | |||
| 88397bf419 | |||
| 1138d5935b | |||
| 8276ba080e | |||
| 37d13d80f5 | |||
| 1c97536ab1 | |||
| ca8f028cc0 | |||
| 300f7bfe4b | |||
| 82dc9c40f7 | |||
| 44a20fe5ee | |||
| b950a64406 | |||
| c5f374f66f | |||
| ae99f87db0 | |||
| 5112e3e79b | |||
| c1e27ccacc | |||
| b0bd871997 | |||
| c30bc24fd6 | |||
| 648f0e9e84 | |||
| d2bc5672e0 | |||
| 5c87ec7c2f | |||
| d66c184ed2 | |||
| 12f25d8bfc | |||
| cbe61397a3 | |||
| deb8c54da3 | |||
| 5dab70fab4 | |||
| 665b6bed66 | |||
| f4a90defc3 | |||
| 46f63cbd55 | |||
| c77142a16f | |||
| eb7c22f52a | |||
| e86e51f06f | |||
| db74ea3001 | |||
| 99579ac29c | |||
| 2f6540e008 | |||
| 72c3b80529 | |||
| 8126a83c50 | |||
| 210c432b37 | |||
| f92e90603f | |||
| 14172791e9 | |||
| e4e2f524d4 | |||
| fc7b16f6a7 | |||
| 6ba3ae74d5 | |||
| c71d1598d8 | |||
| 5950657b0e | |||
| 0e1a48cd06 | |||
| 98245e5f20 | |||
| 0b6ddca981 | |||
| ea1468c892 | |||
| e8e64bf510 | |||
| aaf8daf0d7 | |||
| 8af1abc4e1 | |||
| 48ab59feb3 | |||
| f64d5073d4 | |||
| 53b9f10ac8 | |||
| 75606e4df7 | |||
| 0643b7f351 | |||
| 5b3b4e0614 | |||
| 8d039d1348 | |||
| 87eea73081 | |||
| 67a6fb4c87 | |||
| 23584c9862 | |||
| 2f767125c4 | |||
| c73e07d42d | |||
| 6b5d7e6057 | |||
| 076dc48e89 | |||
| bb208ab36a | |||
| 60e35001b6 | |||
| 29a0d7ea02 | |||
| 1f2b7f7acf | |||
| 39e5cb63ef | |||
| 5b5928fb8d | |||
| 08068743a5 | |||
| 35eae1adcf | |||
| e85d357abd | |||
| 31396d8109 | |||
| 664debc0bc | |||
| 56c31c76b3 | |||
| 2d1a71f3d1 | |||
| c67cd73058 | |||
| 2572cb4352 | |||
| c9f6c7ccac | |||
| b6520b0dbc | |||
| 9fe8142117 | |||
| 57cdd25b5f | |||
| 1005ee5d52 | |||
| d9bb9f9a07 | |||
| 75a6fabe61 | |||
| 75e29b69cc | |||
| ffd3937538 | |||
| 575587e4c5 | |||
| 2ce59d7c92 | |||
| 3b3d6e2a73 | |||
| f2a467fdbf | |||
| 867481c66d | |||
| fc8065b7a8 | |||
| 28f1ed9205 | |||
| 568266bc9a | |||
| 7739f341b8 | |||
| 2b501f191c | |||
| aa2ed0fda1 | |||
| 51cba074d7 | |||
| 2c945c663c | |||
| d9a18ecabb | |||
| eab7931afb | |||
| 99b6c92377 | |||
| 4d56f1163b | |||
| 07cf835551 | |||
| 061c0c1a90 | |||
| 1ed18c05ef | |||
| ca134c8a60 | |||
| 7ba67fd852 | |||
| 7675c2c411 | |||
| 7c71363af8 | |||
| d48926eb2a | |||
| d843e5e06f | |||
| a2e564738e | |||
| 54a797a0b1 | |||
| a78c1ecb4d | |||
| 4771c78001 | |||
| b7a5106f36 | |||
| 4bcbdc50ff | |||
| 5d72b35c7f | |||
| ff50ac215c | |||
| 3fe3809416 | |||
| 363d337a72 | |||
| f89b31c940 | |||
| 630c3e84dd | |||
| d43202f137 | |||
| 0964043489 | |||
| e9b31a545d | |||
| 7f28a88374 | |||
| 07ea45bf84 | |||
| a86d35f721 | |||
| d6c28a0ff6 | |||
| a7af3ca7bc | |||
| cda08a9843 | |||
| 7588774237 | |||
| 683373b263 | |||
| 673ec8fb5d | |||
| 63bacaa671 | |||
| e3a3d6a505 | |||
| 175171dda1 | |||
| a4b63b88c8 | |||
| c3675fdfe7 | |||
| 2fa08519b6 | |||
| 065627060b | |||
| 2982b6a808 | |||
| c3f56ecd40 | |||
| fe9e6c1afd | |||
| 73394ae929 | |||
| c754fc9ff4 | |||
| 1ca6208057 | |||
| cdfae4d4df | |||
| c3da5469ca | |||
| 79f2d8ba76 | |||
| 7b1bdb92a6 | |||
| f9a075eed1 | |||
| 32e4193557 | |||
| 133c3e7c0a | |||
| 6ce3cea682 | |||
| 250e77d18b | |||
| e3bfe006e9 | |||
| 76aeecdc68 | |||
| 15d2ee3e76 | |||
| b066db479f | |||
| a69ff2789a | |||
| 4df235d64a | |||
| 11524457e3 | |||
| 8f7364293e | |||
| 56f5fa1fbe | |||
| b075f0f7e9 | |||
| 0ca32afe8d | |||
| 32c2f257a9 | |||
| a7066ccbe1 | |||
| 09248e0efe | |||
| e2ef22fc5f | |||
| 75f825e4f8 | |||
| 9b8d5198e4 | |||
| e09f747727 | |||
| 16e6171750 | |||
| f885e887ea | |||
| 5096f7fbc5 | |||
| f16560c1ee | |||
| d0f4860759 | |||
| 34d36c6de1 | |||
| 042b850b5d | |||
| 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 | |||
| c079c3c6d9 | |||
| cb782a7557 | |||
| 502d8fc087 | |||
| 6295881138 | |||
| 17f71ba680 | |||
| 5fe7adb87b | |||
| 41684d091b | |||
| 9f6c3347cb | |||
| aa321989c4 | |||
| 0f1565cb4b | |||
| 3996110ebd | |||
| 3422ad4c84 | |||
| 3c07e65b9a | |||
| 287df91661 | |||
| 776bc3135a | |||
| 4638958c9f | |||
| 1f24fe46c4 | |||
| ccbcab8822 | |||
| 094e9cdac2 | |||
| 5c35f36b3a | |||
| d7ec2b57a4 | |||
| 373a1e0312 | |||
| e9fe3975bb | |||
| 32a73b55d4 | |||
| 0426312d6c | |||
| ede2335fb3 | |||
| 709f1a1ccb | |||
| 24b785cbfb | |||
| acc3f2f418 | |||
| d4a653d6a9 | |||
| 7e74d25447 | |||
| 3445db41ce | |||
| d1bd02a6f5 | |||
| 24cd72e088 | |||
| 47f7558c38 | |||
| de747b7b33 | |||
| 8e8b73fbd9 | |||
| e966e33e8f | |||
| bd0ca5babd | |||
| f6bacea1ae |
@@ -0,0 +1 @@
|
|||||||
|
1.2.1
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "restricted",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": []
|
"ignore": []
|
||||||
|
|||||||
+21
-7
@@ -1,6 +1,8 @@
|
|||||||
# Welcome to GitBook's contributing guide!
|
# Welcome to GitBook's contributing guide!
|
||||||
|
|
||||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute.
|
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
|
||||||
|
|
||||||
|
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
|
||||||
|
|
||||||
## Types of Contributions
|
## Types of Contributions
|
||||||
|
|
||||||
@@ -45,13 +47,25 @@ Any contribution you make can be made to the code located in this repository. In
|
|||||||
|
|
||||||
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
||||||
|
|
||||||
##### GitHub Codespaces:
|
|
||||||
|
|
||||||
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
|
|
||||||
|
|
||||||
#### 2. Create a working branch and start with your changes
|
#### 2. Create a working branch and start with your changes
|
||||||
|
|
||||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes!
|
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
|
||||||
|
|
||||||
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
|
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
|
||||||
|
|
||||||
|
You can easily do this by running the command `nvm use`.
|
||||||
|
|
||||||
|
To start your local version of GitBook, run the command `bun dev`.
|
||||||
|
|
||||||
|
#### 4. Preview your changes
|
||||||
|
|
||||||
|
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
||||||
|
|
||||||
|
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
||||||
|
|
||||||
|
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
@@ -71,4 +85,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
|
|||||||
|
|
||||||
### Your PR is merged
|
### Your PR is merged
|
||||||
|
|
||||||
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ runs:
|
|||||||
- name: 🏗 Prepare Playwright env
|
- name: 🏗 Prepare Playwright env
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version')
|
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
||||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||||
- name: 🧰 Cache Playwright browser binaries
|
- name: 🧰 Cache Playwright browser binaries
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
with:
|
with:
|
||||||
path: '~/.cache/ms-playwright'
|
path: '~/.cache/ms-playwright'
|
||||||
@@ -24,6 +24,7 @@ runs:
|
|||||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
working-directory: packages/gitbook
|
||||||
run: |
|
run: |
|
||||||
bun x playwright install --with-deps chromium
|
bun x playwright install --with-deps chromium
|
||||||
|
|
||||||
@@ -31,5 +32,6 @@ runs:
|
|||||||
- name: 🏗 Install Playwright OS dependencies
|
- name: 🏗 Install Playwright OS dependencies
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
working-directory: packages/gitbook
|
||||||
run: |
|
run: |
|
||||||
bun x playwright install-deps
|
bun x playwright install-deps
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: 'Setup Bun'
|
||||||
|
description: 'Install Bun and cache dependencies'
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
|
||||||
|
# and impacting bandwidth there.
|
||||||
|
- name: Cache bun
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
- name: Setup bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version-file: '.bun-version'
|
||||||
+246
-54
@@ -4,7 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Cloudflare Pages
|
name: Deploy to Cloudflare Pages
|
||||||
@@ -12,29 +13,22 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
deployments: write
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
outputs:
|
outputs:
|
||||||
deployment_url: ${{ steps.cloudflare.outputs.url }}
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Cache Next.js build
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/.next/cache
|
|
||||||
# Generate a new cache whenever packages or source files change.
|
|
||||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
|
||||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
|
|
||||||
- name: Sets env vars for production
|
- name: Sets env vars for production
|
||||||
run: |
|
run: |
|
||||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
@@ -45,33 +39,196 @@ jobs:
|
|||||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||||
- name: Build Next.js with next-on-pages
|
- name: Build Next.js with next-on-pages
|
||||||
run: bun run build:cloudflare
|
run: bun run turbo build:cloudflare
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
- id: cloudflare
|
SENTRY_RELEASE: ${{ github.sha }}
|
||||||
name: Publish to Cloudflare Pages
|
- id: deploy
|
||||||
uses: cloudflare/pages-action@v1
|
name: Deploy to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.11.0
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
|
|
||||||
directory: ./.vercel/output/static
|
|
||||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '3.82.0'
|
||||||
|
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "ID: ${{ steps.cloudflare.outputs.id }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
echo "URL: ${{ steps.cloudflare.outputs.url }}"
|
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
|
||||||
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
|
|
||||||
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
|
|
||||||
- name: Archive build output
|
- name: Archive build output
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-output
|
name: build-output
|
||||||
path: .vercel/
|
path: .vercel/
|
||||||
|
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
|
||||||
|
- name: Update Deployment Status to Success
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
deploy-v2-vercel:
|
||||||
|
name: Deploy v2 to Vercel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Sets env vars for production
|
||||||
|
run: |
|
||||||
|
echo "VERCEL_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
|
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
|
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
||||||
|
if: startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Sets env vars for preview
|
||||||
|
run: |
|
||||||
|
echo "VERCEL_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
|
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
|
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: bun run vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||||
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
SENTRY_RELEASE: ${{ github.sha }}
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
|
||||||
|
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
|
deploy-v2-cloudflare:
|
||||||
|
name: Deploy v2 to Cloudflare Worker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Sets env vars for production
|
||||||
|
run: |
|
||||||
|
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
|
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
||||||
|
if: startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Sets env vars for preview
|
||||||
|
run: |
|
||||||
|
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
|
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Build worker
|
||||||
|
run: bun run turbo build:v2:cloudflare
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||||
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
SENTRY_RELEASE: ${{ github.sha }}
|
||||||
|
- id: deploy
|
||||||
|
name: Deploy to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.11.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '3.82.0'
|
||||||
|
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
|
comment-deployments:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Comment Deployments
|
||||||
|
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
needs:
|
||||||
|
- deploy
|
||||||
|
- deploy-v2-vercel
|
||||||
|
- deploy-v2-cloudflare
|
||||||
|
steps:
|
||||||
|
- name: Find GitHub Comment
|
||||||
|
uses: peter-evans/find-comment@v3
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-author: 'github-actions[bot]'
|
||||||
|
body-includes: 'Summary of the deployments'
|
||||||
|
|
||||||
|
- name: Create or update GitHub comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v4
|
||||||
|
with:
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
body: |
|
||||||
|
Summary of the deployments:
|
||||||
|
|
||||||
|
### Version 1 (production)
|
||||||
|
|
||||||
|
| Version | URL | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||||
|
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
|
### Version 2 (experimental)
|
||||||
|
|
||||||
|
| Version | URL | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||||
|
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
|
### Test content
|
||||||
|
|
||||||
|
| Site | v1 | v2 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com) |
|
||||||
|
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||||
|
edit-mode: replace
|
||||||
visual-testing:
|
visual-testing:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing
|
name: Visual Testing
|
||||||
@@ -79,10 +236,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
- name: Setup Playwright
|
||||||
@@ -90,8 +245,39 @@ jobs:
|
|||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-test-results
|
||||||
|
path: packages/gitbook/test-results/
|
||||||
|
retention-days: 3
|
||||||
|
visual-testing-customers:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers
|
||||||
|
needs: deploy
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers'
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-test-results-customers
|
||||||
|
path: packages/gitbook/test-results/
|
||||||
|
retention-days: 3
|
||||||
pagespeed-testing:
|
pagespeed-testing:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing
|
name: PageSpeed Testing
|
||||||
@@ -99,18 +285,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run pagespeed tests
|
- name: Run pagespeed tests
|
||||||
run: bun ./tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||||
env:
|
env:
|
||||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -118,10 +302,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -133,10 +315,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -148,25 +328,37 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun unit
|
- run: bun unit
|
||||||
|
build-oss:
|
||||||
|
# CI to check that the repository builds correctly on a machine without the credentials
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build (Open Source)
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ''
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- run: bun run build
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -31,5 +29,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
publish: npm run release
|
publish: npm run release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||||
|
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ 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: ./.github/composite/setup-bun
|
||||||
|
- 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
-29
@@ -5,16 +5,6 @@ node_modules
|
|||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -24,25 +14,11 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# Turbo
|
||||||
.env*.local
|
.turbo
|
||||||
|
|
||||||
# vercel
|
# Vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# Env files
|
||||||
*.tsbuildinfo
|
.env.local
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# visual tests
|
|
||||||
screenshots/
|
|
||||||
|
|
||||||
# Sentry Config File
|
|
||||||
.sentryclirc
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
|
|
||||||
# Generated public files
|
|
||||||
/public/~gitbook/static/
|
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
.next
|
.next
|
||||||
|
.vercel
|
||||||
|
.open-next
|
||||||
|
|
||||||
# Generated
|
# Generated
|
||||||
packages/emoji-codepoints/index.ts
|
packages/emoji-codepoints/index.ts
|
||||||
|
packages/gitbook/public/~gitbook/static/
|
||||||
|
packages/icons/src/data/*.json
|
||||||
|
|
||||||
|
# Build files
|
||||||
|
dist/
|
||||||
|
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></a>
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||||
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
|
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
|
||||||
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
|
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
|
||||||
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
|
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
||||||
|
|
||||||
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p>
|
<p align="center">This repository contains the open source code used to render GitBook's published content.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
||||||
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (Version: >=18.x)
|
- Node.js (Version: >=20.6)
|
||||||
- Bun
|
- Use nvm for easy Node management
|
||||||
|
- Bun (Version: >=1.2.1)
|
||||||
|
- We use a text-based lockfile which isn't supported below 1.2.1
|
||||||
|
|
||||||
### Set up
|
### Set up
|
||||||
|
|
||||||
@@ -46,19 +48,28 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
git clone https://github.com/gitbookIO/gitbook.git
|
git clone https://github.com/gitbookIO/gitbook.git
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install the project's dependencies through Bun.
|
2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
|
||||||
|
|
||||||
|
3. Install the project's dependencies through Bun.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start your local development server.
|
4. Start your local development server.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com).
|
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
|
||||||
|
- http://localhost:3000/docs.gitbook.com
|
||||||
|
- http://localhost:3000/open-source.gitbook.io/midjourney
|
||||||
|
|
||||||
|
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||||
|
|
||||||
### Other development commands
|
### Other development commands
|
||||||
|
|
||||||
@@ -69,9 +80,29 @@ Then open the space in your web browser, using http://localhost:3000/<host>/<pat
|
|||||||
|
|
||||||
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
||||||
|
|
||||||
|
## Fonts and Icons
|
||||||
|
|
||||||
|
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
|
||||||
|
|
||||||
|
```
|
||||||
|
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
|
||||||
|
```
|
||||||
|
|
||||||
|
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
|
||||||
|
|
||||||
|
If you are GitBook staff, you'll need our NPM token in your local environment.
|
||||||
|
|
||||||
|
```
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
NPM_TOKEN_READONLY=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
and then reinstall dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
GitBook's rendering engine is fully open-source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||||
|
|
||||||
### Types of contributions
|
### Types of contributions
|
||||||
|
|
||||||
@@ -79,7 +110,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
|
|||||||
|
|
||||||
#### Translations
|
#### Translations
|
||||||
|
|
||||||
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI.
|
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
|
||||||
|
|
||||||
#### Bugs
|
#### Bugs
|
||||||
|
|
||||||
@@ -90,7 +121,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
|
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
|
||||||
>
|
>
|
||||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
|
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
|
||||||
>
|
>
|
||||||
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
||||||
>
|
>
|
||||||
@@ -106,10 +137,30 @@ On the con side, you become responsible for the reliability of your published si
|
|||||||
|
|
||||||
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
|
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
|
||||||
|
|
||||||
If you plan to distribute the code, you must the source code public to comply with GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
If you plan to distribute the code, you must make the source code public to comply with the GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
||||||
|
|
||||||
See `LICENSE` for more information.
|
See `LICENSE` for more information.
|
||||||
|
|
||||||
|
## Badges
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=F4E28D"></a>
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=FDA599"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
```md
|
||||||
|
[](https://gitbook.com/)
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="https://gitbook.com">
|
||||||
|
<img
|
||||||
|
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
GitBook wouldn't be possible without these projects:
|
GitBook wouldn't be possible without these projects:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[install.scopes]
|
||||||
|
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||||
@@ -1,668 +0,0 @@
|
|||||||
import { argosScreenshot } from '@argos-ci/playwright';
|
|
||||||
import {
|
|
||||||
CustomizationHeaderPreset,
|
|
||||||
CustomizationLocale,
|
|
||||||
CustomizationSettings,
|
|
||||||
} from '@gitbook/api';
|
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import rison from 'rison';
|
|
||||||
|
|
||||||
import { getContentTestURL } from '../tests/utils';
|
|
||||||
|
|
||||||
interface Test {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
run?: (page: Page) => Promise<unknown>;
|
|
||||||
fullPage?: boolean;
|
|
||||||
screenshot?: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestsCase {
|
|
||||||
name: string;
|
|
||||||
baseUrl: string;
|
|
||||||
tests: Array<Test>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allLocales: CustomizationLocale[] = [
|
|
||||||
CustomizationLocale.Fr,
|
|
||||||
CustomizationLocale.Es,
|
|
||||||
CustomizationLocale.Ja,
|
|
||||||
CustomizationLocale.Zh,
|
|
||||||
];
|
|
||||||
|
|
||||||
async function waitForCookiesDialog(page: Page) {
|
|
||||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
|
||||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
|
||||||
const reject = dialog.getByRole('button', { name: 'Reject' });
|
|
||||||
await expect(accept).toBeVisible();
|
|
||||||
await expect(reject).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
const testCases: TestsCase[] = [
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Single Variant)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Home',
|
|
||||||
url: '',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'No variants dropdown',
|
|
||||||
url: '',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-results"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Not found',
|
|
||||||
url: 'content-not-found',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Multi Variants)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Variants dropdown',
|
|
||||||
url: '',
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
|
||||||
await spaceDrowpdown.waitFor();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Default variant',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'RFC variant',
|
|
||||||
url: 'v/rfcs',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Navigation when switching variant)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (Public)',
|
|
||||||
url: 'api-multi-versions/reference/api-reference/pets',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (Share link)',
|
|
||||||
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (VA)',
|
|
||||||
screenshot: false,
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'c26190fc-74b2-4b54-9fc7-df9941104953';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook',
|
|
||||||
baseUrl: 'https://docs.gitbook.com',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Home',
|
|
||||||
url: '',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-results"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Not found',
|
|
||||||
url: 'content-not-found',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Versioning',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Revision',
|
|
||||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'PDF',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'PDF',
|
|
||||||
url: '~gitbook/pdf?limit=10',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Content tests',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Text',
|
|
||||||
url: 'text-page',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Long text',
|
|
||||||
url: 'text-page/long-text',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Images',
|
|
||||||
url: 'blocks/block-images',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inline Images',
|
|
||||||
url: 'blocks/inline-images',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tabs',
|
|
||||||
url: 'blocks/tabs',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Hints',
|
|
||||||
url: 'blocks/hints',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Integration Blocks',
|
|
||||||
url: 'blocks/integrations',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tables',
|
|
||||||
url: 'blocks/tables',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Expandables',
|
|
||||||
url: 'blocks/expandables',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'API Blocks',
|
|
||||||
url: 'blocks/api-blocks',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Headings',
|
|
||||||
url: 'blocks/headings',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Marks',
|
|
||||||
url: 'blocks/marks',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Emojis',
|
|
||||||
url: 'blocks/emojis',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Links',
|
|
||||||
url: 'blocks/links',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Lists',
|
|
||||||
url: 'blocks/lists',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Code',
|
|
||||||
url: 'blocks/code',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cards',
|
|
||||||
url: 'blocks/cards',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Math',
|
|
||||||
url: 'blocks/math',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Embeds',
|
|
||||||
url: 'blocks/embeds',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Annotations',
|
|
||||||
url: 'blocks/annotations',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
|
||||||
await page.click('[data-testid="annotation-button"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Page options',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Hidden',
|
|
||||||
url: 'page-options/page-hidden',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With cover',
|
|
||||||
url: 'page-options/page-with-cover',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With hero cover',
|
|
||||||
url: 'page-options/page-with-hero-cover',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With cover and no TOC',
|
|
||||||
url: 'page-options/page-with-cover-and-no-toc',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Customization',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Without header',
|
|
||||||
url: getCustomizationURL({
|
|
||||||
header: {
|
|
||||||
preset: CustomizationHeaderPreset.None,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Share links',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-share-links/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Valid link',
|
|
||||||
url: 'Fc6mMII9FKgnwm7qqynx/',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Invalid link',
|
|
||||||
url: 'invalid/',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(
|
|
||||||
page.getByText('Authentication missing to access this content'),
|
|
||||||
).toBeVisible();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Space',
|
|
||||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-space/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'First',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `first?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Second',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `second?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Collection',
|
|
||||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-collection/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Root',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Primary (Space A)',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spacea?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Space B',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spaceb?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Space C',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spacec?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Space (custom domain)',
|
|
||||||
baseUrl: `https://test.gitbook.community/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Root',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'First',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `first?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Custom page',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `custom-page?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inner page',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `custom-page/inner-page?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Languages',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: allLocales.map((locale) => ({
|
|
||||||
name: locale,
|
|
||||||
url: getCustomizationURL({
|
|
||||||
internationalization: {
|
|
||||||
locale,
|
|
||||||
inherit: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
run: async (page) => {
|
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
test.describe(testCase.name, () => {
|
|
||||||
for (const testEntry of testCase.tests) {
|
|
||||||
test(testEntry.name, async ({ page, baseURL }) => {
|
|
||||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
|
||||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
|
||||||
await page.goto(url);
|
|
||||||
if (testEntry.run) {
|
|
||||||
await testEntry.run(page);
|
|
||||||
}
|
|
||||||
if (testEntry.screenshot !== false) {
|
|
||||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
|
||||||
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
|
|
||||||
argosCSS: `
|
|
||||||
/* Hide Intercom */
|
|
||||||
.intercom-lightweight-app {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fullPage: testEntry.fullPage ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a URL with customization settings.
|
|
||||||
*/
|
|
||||||
function getCustomizationURL(partial: Partial<CustomizationSettings>): string {
|
|
||||||
const encoded = rison.encode_object(partial);
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
searchParams.set('customization', encoded);
|
|
||||||
|
|
||||||
return `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
+30
-82
@@ -1,96 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@changesets/cli": "^2.27.12",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"turbo": "^2.4.1-canary.2",
|
||||||
|
"vercel": "^39.3.0"
|
||||||
|
},
|
||||||
|
"packageManager": "bun@1.1.18",
|
||||||
|
"overrides": {
|
||||||
|
"@codemirror/state": "6.4.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "turbo run dev",
|
||||||
"build": "next build",
|
"dev:v2": "turbo run dev:v2",
|
||||||
"build:cloudflare": "next-on-pages",
|
"build": "turbo run build",
|
||||||
"start": "next start",
|
"build:v2": "turbo run build:v2",
|
||||||
"lint": "next lint",
|
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"lint:fix": "turbo run lint -- --fix",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "prettier ./ --ignore-unknown --write",
|
"format": "prettier ./ --ignore-unknown --write",
|
||||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
"format:check": "prettier ./ --ignore-unknown --list-different",
|
||||||
"typecheck": "tsc --noEmit",
|
"unit": "turbo run unit",
|
||||||
"unit": "bun test {src,packages}",
|
"e2e": "turbo run e2e",
|
||||||
"e2e": "playwright test",
|
"e2e-customers": "turbo run e2e-customers",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"release": "changeset publish",
|
"release": "turbo run release && changeset publish",
|
||||||
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
|
"release:preview": "turbo run release:preview",
|
||||||
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"patchedDependencies": {
|
||||||
"@geist-ui/icons": "^1.0.2",
|
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||||
"@gitbook/api": "^0.56.0",
|
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
|
||||||
"@sentry/nextjs": "^7.94.1",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
|
||||||
"@upstash/redis": "^1.27.1",
|
|
||||||
"ajv": "^8.12.0",
|
|
||||||
"assert-never": "^1.2.1",
|
|
||||||
"bun-types": "^1.0.7",
|
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"content-security-policy-merger": "^1.0.0",
|
|
||||||
"framer-motion": "^10.16.14",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"jsontoxml": "^1.0.1",
|
|
||||||
"katex": "^0.16.9",
|
|
||||||
"mathjax": "^3.2.2",
|
|
||||||
"memoizee": "^0.4.15",
|
|
||||||
"next": "^14.1.3",
|
|
||||||
"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",
|
|
||||||
"react": "^18",
|
|
||||||
"react-dom": "^18",
|
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
|
||||||
"recoil": "^0.7.7",
|
|
||||||
"rehype-sanitize": "^6.0.0",
|
|
||||||
"rehype-stringify": "^10.0.0",
|
|
||||||
"remark-gfm": "^4.0.0",
|
|
||||||
"remark-parse": "^11.0.0",
|
|
||||||
"remark-rehype": "^11.1.0",
|
|
||||||
"rison": "^0.1.1",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"shiki": "^1.2.0",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
|
||||||
"tailwind-shades": "^1.1.2",
|
|
||||||
"unified": "^11.0.4",
|
|
||||||
"url-join": "^5.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@argos-ci/playwright": "^2.0.0",
|
|
||||||
"@changesets/cli": "^2.27.7",
|
|
||||||
"@cloudflare/next-on-pages": "^1.11.3",
|
|
||||||
"@cloudflare/workers-types": "^4.20231218.0",
|
|
||||||
"@playwright/test": "^1.42.1",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/jsontoxml": "^1.0.5",
|
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
|
||||||
"@types/katex": "^0.16.5",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/object-hash": "^3.0.6",
|
|
||||||
"@types/parse-cache-control": "^1.0.4",
|
|
||||||
"@types/psi": "^4.1.6",
|
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"@types/rison": "^0.0.9",
|
|
||||||
"autoprefixer": "^10",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "13.5.6",
|
|
||||||
"eslint-plugin-import": "^2.29.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"postcss": "^8",
|
|
||||||
"prettier": "^3.0.3",
|
|
||||||
"psi": "^4.1.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.wrangler
|
||||||
|
worker-configuration.d.ts
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# @gitbook/cache-do
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- b7a5106: Disable cloudflare observability in production
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
|
||||||
|
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 56f5fa1: Enable Workers observability with a sampling of 0.1
|
||||||
@@ -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.1.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||||
|
"lru_map": "^0.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"wrangler": "3.82.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "wrangler types --experimental-include-runtime",
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"release": "wrangler deploy",
|
||||||
|
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"bin",
|
||||||
|
"data",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
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 = false
|
||||||
|
|
||||||
|
[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 = 0.1
|
||||||
@@ -1 +1 @@
|
|||||||
index.ts
|
dist/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import emojisRaws from 'emoji-assets/emoji.json';
|
import emojisRaws from 'emoji-assets/emoji.json';
|
||||||
|
|
||||||
interface EmojiData {
|
interface EmojiData {
|
||||||
@@ -20,7 +21,8 @@ Object.entries(emojis).forEach(([key, value]) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
'index.ts',
|
path.resolve(__dirname, 'dist/index.ts'),
|
||||||
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
|
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"exports": "./index.ts",
|
"exports": "./dist/index.ts",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"emoji-assets": "^8.0.0"
|
"emoji-assets": "^8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "bun ./build.ts"
|
"generate": "bun ./build.ts",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# cloudflare
|
||||||
|
.open-next
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# gitbook-v2
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- cfccc44: Setup structure and deployment for new version
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# GitBook V2
|
||||||
|
|
||||||
|
This package contains the code for an experiment new version of the GitBook renderer.
|
||||||
|
|
||||||
|
The goals of this new version are:
|
||||||
|
|
||||||
|
- Optimize rendering to leverage Next.js ISR and static rendering
|
||||||
|
- Leverage Node.js runtime and bigger instances to render bigger content
|
||||||
|
- Evaluate Vercel and Cloudflare deployment options
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
#### Vercel
|
||||||
|
|
||||||
|
The new version is deployed automatically to Vercel using a Github Action on every push.
|
||||||
|
|
||||||
|
#### Cloudflare with Opennext-js
|
||||||
|
|
||||||
|
The new version is deployed automatically to Cloudflare using a Github Action on every push.
|
||||||
|
|
||||||
|
## Potential issues
|
||||||
|
|
||||||
|
- [ ] We can't use `dynamicIO` because our root layout needs to fetch the params (https://github.com/vercel/next.js/discussions/71927)
|
||||||
|
- [ ] How to do `nonce` for static resources when we can't use `headers()` (dynamic API) (https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy)
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
#### Middleware:
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
- Route between dynamic and static routes
|
||||||
|
- If a token is in the cookies => dynamic route
|
||||||
|
- If a token is in the url => dynamic route
|
||||||
|
- Else => static route
|
||||||
|
- High performances:
|
||||||
|
- It should not rely on data fetching
|
||||||
|
|
||||||
|
#### URLs to support:
|
||||||
|
|
||||||
|
Content URLs:
|
||||||
|
|
||||||
|
- `docs.company.com/page`
|
||||||
|
- Middleware: rewrite to `/static/url/docs.company.com/page`
|
||||||
|
- Page component renders the page
|
||||||
|
- `docs.company2.com/section/page`
|
||||||
|
- Middleware: rewrite to `/static/url/docs.company2.com/section/page`
|
||||||
|
- Page component renders the page
|
||||||
|
- `docs.company3.com/section/sitespace/page`
|
||||||
|
- Middleware: rewrite to `/static/url/docs.company3.com/section/sitespace/page`
|
||||||
|
- Page component renders the page
|
||||||
|
|
||||||
|
Content URLs (adaptive content or visitor authentication):
|
||||||
|
|
||||||
|
- `docs.company.com/page`
|
||||||
|
- Middleware:
|
||||||
|
- If a token is in the cookies or url => dynamic route
|
||||||
|
- Else => rewrite to `/dynamic/url/docs.company.com/page`
|
||||||
|
- Page component renders the page
|
||||||
|
- Read the token from the url query params or cookies
|
||||||
|
|
||||||
|
|
||||||
|
PDF export URLs:
|
||||||
|
|
||||||
|
- `docs.company.com/page/~gitbook/pdf`
|
||||||
|
- Middleware: rewrite to `/dynamic/pdf/docs.company.com/page`
|
||||||
|
- Page component renders the page
|
||||||
|
|
||||||
|
Images resizing:
|
||||||
|
|
||||||
|
- `docs.company.com/~gitbook/image`
|
||||||
|
- Middleware: rewrite to `/dynamic/image/docs.company.com`
|
||||||
|
- Route handlers process the request
|
||||||
|
|
||||||
|
Preview URLs:
|
||||||
|
|
||||||
|
- `open.gitbook.com/sites/site_abc/page` (or `open.gitbook.com/~site/site_abc/page` to stay compatible?)
|
||||||
|
- Middleware: no rewrite
|
||||||
|
- Page component renders the page (dynamic?)
|
||||||
|
- Read the token from the url query params
|
||||||
|
- Read customizations override from the url query params
|
||||||
|
|
||||||
|
|
||||||
|
Space PDF URLs:
|
||||||
|
|
||||||
|
- `open.gitbook.com/spaces/space_abc/pdf`
|
||||||
|
- Middleware: no rewrite
|
||||||
|
- Page component renders the page (dynamic?)
|
||||||
|
- Read the token from the url query params
|
||||||
|
- Read customizations override from the url query params
|
||||||
|
|
||||||
|
Development:
|
||||||
|
|
||||||
|
- `localhost:3000/sites/site_abc/page`
|
||||||
|
|
||||||
|
#### Routes:
|
||||||
|
|
||||||
|
- Content
|
||||||
|
- [ ] `/static/url/[[...url]]/page.tsx`
|
||||||
|
- [ ] `/dynamic/url/[[...url]]/page.tsx`
|
||||||
|
- Images resizing
|
||||||
|
- [ ] `/dynamic/image/[hostname]/route.ts`
|
||||||
|
- PDF export
|
||||||
|
- [ ] `/dynamic/pdf/[[...url]]/page.tsx`
|
||||||
|
- [ ] `/spaces/[space]/pdf/page.tsx`
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
*/
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
useCache: true,
|
||||||
|
|
||||||
|
// We can't use dynamicIO because it doesn't accept reading params in the root layout
|
||||||
|
// dynamicIO: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||||
|
|
||||||
|
import cache from '@opennextjs/cloudflare/kvCache';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
default: {
|
||||||
|
override: {
|
||||||
|
wrapper: 'cloudflare-node',
|
||||||
|
converter: 'edge',
|
||||||
|
incrementalCache: async () => cache,
|
||||||
|
tagCache: 'dummy',
|
||||||
|
queue: 'dummy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
middleware: {
|
||||||
|
external: true,
|
||||||
|
override: {
|
||||||
|
wrapper: 'cloudflare-edge',
|
||||||
|
converter: 'edge',
|
||||||
|
proxyExternalRequest: 'fetch',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dangerous: {
|
||||||
|
enableCacheInterception: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "gitbook-v2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"next": "canary",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@gitbook/api": "^0.93.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@opennextjs/cloudflare": "^0.4.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev:v2": "next",
|
||||||
|
"build": "next build",
|
||||||
|
"build:v2": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||||
|
"dev:v2:cloudflare": "wrangler dev --port 8771"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
|
||||||
|
import { createDynamicSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ url: string[] }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { url } = await params;
|
||||||
|
const context = await createDynamicSiteContext(url);
|
||||||
|
|
||||||
|
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SiteContentPage } from '@/components/routes/SiteContentPage';
|
||||||
|
import { createDynamicSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
|
||||||
|
const { url } = await params;
|
||||||
|
const context = await createDynamicSiteContext(url);
|
||||||
|
|
||||||
|
return <SiteContentPage context={context} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
|
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
|
||||||
|
import { getSiteCacheTag } from '@/lib/cache';
|
||||||
|
import { createStaticSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ url: string[] }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const { url } = await params;
|
||||||
|
const context = await createStaticSiteContext(url);
|
||||||
|
|
||||||
|
cacheTag(getSiteCacheTag(context.siteId));
|
||||||
|
|
||||||
|
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
|
import { createStaticSiteContext } from '@/lib/context';
|
||||||
|
import { getSiteCacheTag } from '@/lib/cache';
|
||||||
|
import { SiteContentPage } from '@/components/routes/SiteContentPage';
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const { url } = await params;
|
||||||
|
const context = await createStaticSiteContext(url);
|
||||||
|
|
||||||
|
cacheTag(getSiteCacheTag(context.siteId));
|
||||||
|
|
||||||
|
return <SiteContentPage context={context} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { GitBookSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout component to render the site content.
|
||||||
|
*/
|
||||||
|
export async function SiteContentLayout({
|
||||||
|
context,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { api } = context;
|
||||||
|
const { data: publishedSite } = await api.orgs.getPublishedContentSite(
|
||||||
|
context.organizationId,
|
||||||
|
context.siteId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<h1>{publishedSite.site.title}</h1>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { GitBookSiteContext } from '@/lib/context';
|
||||||
|
|
||||||
|
export async function SiteContentPage({ context }: { context: GitBookSiteContext }) {
|
||||||
|
const { api } = context;
|
||||||
|
const { data } = await api.orgs.getPublishedContentSite(context.organizationId, context.siteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Found site {data.site.id}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function getSiteCacheTag(siteId: string) {
|
||||||
|
return `sites/${siteId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { GitBookAPI } from '@gitbook/api';
|
||||||
|
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic context about rendering content.
|
||||||
|
*/
|
||||||
|
export interface GitBookContext {
|
||||||
|
/**
|
||||||
|
* API client to fetch data from GitBook.
|
||||||
|
*/
|
||||||
|
api: GitBookAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context when rendering a site.
|
||||||
|
*/
|
||||||
|
export interface GitBookSiteContext extends GitBookContext {
|
||||||
|
/**
|
||||||
|
* ID of the organization.
|
||||||
|
*/
|
||||||
|
organizationId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site ID.
|
||||||
|
*/
|
||||||
|
siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a site context, when rendering a static page.
|
||||||
|
*/
|
||||||
|
export async function createStaticSiteContext(url: string[]): Promise<GitBookSiteContext> {
|
||||||
|
const context = createStaticContext();
|
||||||
|
return fetchSiteContext(url, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a site context, when rendering a dynamic page.
|
||||||
|
*/
|
||||||
|
export async function createDynamicSiteContext(url: string[]): Promise<GitBookSiteContext> {
|
||||||
|
const context = await createDynamicContext();
|
||||||
|
return fetchSiteContext(url, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the context of a site for a given URL and a base context.
|
||||||
|
*/
|
||||||
|
async function fetchSiteContext(
|
||||||
|
urlParts: string[],
|
||||||
|
baseContext: GitBookContext,
|
||||||
|
): Promise<GitBookSiteContext> {
|
||||||
|
const { api } = baseContext;
|
||||||
|
const url = getURLFromParams(urlParts);
|
||||||
|
const { data } = await api.urls.getPublishedContentByUrl({
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('redirect' in data) {
|
||||||
|
redirect(data.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseContext,
|
||||||
|
api: new GitBookAPI({
|
||||||
|
endpoint: api.endpoint,
|
||||||
|
authToken: data.apiToken,
|
||||||
|
userAgent: api.userAgent,
|
||||||
|
}),
|
||||||
|
siteId: data.site,
|
||||||
|
organizationId: data.organization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base context when rendering statically.
|
||||||
|
*/
|
||||||
|
function createStaticContext(): GitBookContext {
|
||||||
|
const api = new GitBookAPI({
|
||||||
|
authToken: GITBOOK_API_TOKEN,
|
||||||
|
endpoint: GITBOOK_API_URL,
|
||||||
|
userAgent: GITBOOK_USER_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base context when rendering dynamically.
|
||||||
|
* The context will depend on the request.
|
||||||
|
*/
|
||||||
|
async function createDynamicContext(): Promise<GitBookContext> {
|
||||||
|
const headersSet = await headers();
|
||||||
|
|
||||||
|
const api = new GitBookAPI({
|
||||||
|
authToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
|
||||||
|
endpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
|
||||||
|
userAgent: GITBOOK_USER_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getURLFromParams(input: string[]) {
|
||||||
|
const url = new URL('https://' + input.join('/'));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Default GitBook API URL endpoint.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default GitBook API token.
|
||||||
|
* It can be use to avoid rate-limiting.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User agent to use for API requests.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
// No rewrite
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@v1/*": ["../gitbook/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
main = ".open-next/worker.js"
|
||||||
|
name = "gitbook-v2"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||||
@@ -25,3 +25,6 @@
|
|||||||
|
|
||||||
### Sentry ###
|
### Sentry ###
|
||||||
# SENTRY_DSN=xxx
|
# SENTRY_DSN=xxx
|
||||||
|
|
||||||
|
### Silent logs
|
||||||
|
# SILENT=true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# visual tests
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.sentryclirc
|
||||||
|
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
|
# Generated public files
|
||||||
|
/public/~gitbook/static/*
|
||||||
|
!/public/~gitbook/static/images
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# gitbook
|
||||||
|
|
||||||
|
## 0.6.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- dddb4ec: Fix long tab group description
|
||||||
|
- Updated dependencies [f8d4c76]
|
||||||
|
- Updated dependencies [dddb4ec]
|
||||||
|
- Updated dependencies [f8d4c76]
|
||||||
|
- @gitbook/react-openapi@1.0.1
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
|
||||||
|
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
|
||||||
|
- 95f2aa4: Track new events for site insights when ads are being clicked
|
||||||
|
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
|
||||||
|
- 1138d59: Add support for sidebar background styles
|
||||||
|
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
|
||||||
|
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
|
||||||
|
- 7059c2b: Add support for computed content by fetching computed documents for pages.
|
||||||
|
- c71d159: Track events for site insights using the new dedicated API.
|
||||||
|
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
|
||||||
|
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||||
|
- 7ee9158: Restyle PageAside to use sidebar list styles
|
||||||
|
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
|
||||||
|
- 1417279: Track clicks on links (header, footer, content) for site insights.
|
||||||
|
- 9eca010: Improve the display of recommended questions by streaming them.
|
||||||
|
- 160fca1: new OpenAPI blocks design
|
||||||
|
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
|
||||||
|
- 1823101: Fix internal properties appearing in OpenAPI docs.
|
||||||
|
- 6a073e1: Add antialiasing for text rendering
|
||||||
|
- 8126a83: Improve readability of tables with hover style and vertical dividers
|
||||||
|
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
|
||||||
|
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
|
||||||
|
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
|
||||||
|
- c30bc24: Fix empty sitemap
|
||||||
|
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
|
||||||
|
- 5b4e710: Support llms.txt
|
||||||
|
- b6c3870: Add support for keyboard marks
|
||||||
|
- 6059efe: Fix search no results error showing while there are results
|
||||||
|
- c77142a: Log component stack in Sentry
|
||||||
|
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
|
||||||
|
- 32aa1f9: Handle security issue with cookies on Safari
|
||||||
|
- d935fb1: Don't add extra page scroll when footer is not present
|
||||||
|
- 53de5b1: Fix site section URL resolution in Ask AI sources
|
||||||
|
- 24f5249: Fix vertical section overflow color
|
||||||
|
- 1762f85: Reduce gap between subsequent header buttons
|
||||||
|
- c1e27cc: Fix pass Sentry release properly
|
||||||
|
- 5ae1b88: Fix shrinking page icons
|
||||||
|
- 8f046a9: Start using tint in more places, TOC and PageAside
|
||||||
|
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
|
||||||
|
|
||||||
|
To reduce the load on the API and also avoid errors.
|
||||||
|
|
||||||
|
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
|
||||||
|
- 8cfa67c: Fix default outline list styling
|
||||||
|
- d66c184: Ignore errors from event flushing
|
||||||
|
- 6088fa5: Simplify search results logic to investigate a bug
|
||||||
|
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
|
||||||
|
- 09c7c30: Try to fix error on og image generation
|
||||||
|
- ae99f87: Improve emoji setup, align with GitBook app
|
||||||
|
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
|
||||||
|
- 3a7210d: Fix zoom image view transition on Safari
|
||||||
|
- 718a8a5: Position the variant picker in the ToC
|
||||||
|
- e5dc05e: Update footer styling and allow for more than 4 footer groups
|
||||||
|
- 8276ba0: Make cookies access safer
|
||||||
|
- 1b8a456: Fix Image blocks zoomable behaviour
|
||||||
|
- 56c52e0: Handle Firefox security error on localStorage
|
||||||
|
- 0510b6f: Add section description to SectionGroupTile
|
||||||
|
- 1fcc807: Fix errors from customization not found
|
||||||
|
- 46edde9: Improve the OpenAPI package API
|
||||||
|
- 8af1abc: Improve contrast of search box placeholder
|
||||||
|
- 92b7668: Improve header offset
|
||||||
|
- d9c8d57: Do not dereference before caching OpenAPI spec.
|
||||||
|
- 94876e3: Fix regression issue with page icons for multi-line titles
|
||||||
|
- 47971dc: Fix OG image generation for non-latin characters
|
||||||
|
- 82dc9c4: Simplify the `useHash` algo used.
|
||||||
|
- 128ad20: Ignore cache invalidation error from local backend
|
||||||
|
- ff05e20: Improvements to inline images in headings
|
||||||
|
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
|
||||||
|
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
|
||||||
|
- 48ab59f: Improve colour contrast of list item decoration
|
||||||
|
- d2bc567: Set Sentry release
|
||||||
|
- 37d13d8: Avoid error on fetch by passing a string URL
|
||||||
|
- d3e573c: Generate sitemap for all sections and spaces
|
||||||
|
- f7b801b: Add feedback form to page rating control
|
||||||
|
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
|
||||||
|
- 46f63cb: Fix code format overriding inline link styles
|
||||||
|
- 5950657: Fix emojis display
|
||||||
|
- 528a053: Fix server actions stability leading to no results found sometimes on search
|
||||||
|
- eac1314: Lazy load iframely script to make page more responsive
|
||||||
|
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
|
||||||
|
- 6f54826: New highlight colors
|
||||||
|
- 5c87ec7: Implement a safer way to interact with localStorage.
|
||||||
|
|
||||||
|
If it's disabled on the browser it should not throw error.
|
||||||
|
|
||||||
|
- 02d876e: Fix search UI behaviour
|
||||||
|
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||||
|
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
|
||||||
|
- aaab157: Visual fix for section group in Safari
|
||||||
|
- cbe6139: Fix dynamic tabs infinite loop
|
||||||
|
- 65cc4af: Fix error when accessing a change request not found
|
||||||
|
- 727bde2: Improve and split OpenAPI parser into its own package
|
||||||
|
- 0b6ddca: Fix variant selector contrast for non-default themes
|
||||||
|
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
|
||||||
|
- fde32e2: Force route handler to be dynamic to avoid errors
|
||||||
|
- a025118: Change card layout depending on cover aspect ratio
|
||||||
|
- 300f7bf: Fix search loading state
|
||||||
|
- 29d5979: Disable C/C++ highlight temporarily
|
||||||
|
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
|
||||||
|
- 1c97536: Fix Sentry instrumentation
|
||||||
|
- b0bd871: Even safer localStorage
|
||||||
|
- b950a64: Avoid errors on legacy browsers
|
||||||
|
- 38061bd: Add section groups to section tabs
|
||||||
|
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
|
||||||
|
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
|
||||||
|
- 6691492: Fix viewing PDF from space
|
||||||
|
- e8e64bf: Fix bullet list display on full size blocks
|
||||||
|
- 16194c5: Vertical orientation for sections list on sites without header
|
||||||
|
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
|
||||||
|
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
|
||||||
|
- 1429384: Fix error when accessing some not found pages.
|
||||||
|
- 21cbd9e: Change link color to primary-subtle
|
||||||
|
- 5dab70f: Fix "Parser" language syntax highlighting
|
||||||
|
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
|
||||||
|
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
|
||||||
|
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
|
||||||
|
- 44a20fe: Improve smoothness of scroll listener
|
||||||
|
- 5664e5a: Fix variant dropdown styling in header
|
||||||
|
- 6b50360: Fix view transition error on Safari
|
||||||
|
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
|
||||||
|
- 5112e3e: Fix Sentry instrumentation server-side
|
||||||
|
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
|
||||||
|
- Updated dependencies [d9029c7]
|
||||||
|
- Updated dependencies [6e54a06]
|
||||||
|
- Updated dependencies [162b4b7]
|
||||||
|
- Updated dependencies [e4e2f52]
|
||||||
|
- Updated dependencies [0c03676]
|
||||||
|
- Updated dependencies [3e5e458]
|
||||||
|
- Updated dependencies [46edde9]
|
||||||
|
- Updated dependencies [d9c8d57]
|
||||||
|
- Updated dependencies [ccf2cff]
|
||||||
|
- Updated dependencies [dda0cc6]
|
||||||
|
- Updated dependencies [eb7c22f]
|
||||||
|
- Updated dependencies [ea1468c]
|
||||||
|
- Updated dependencies [648f0e9]
|
||||||
|
- Updated dependencies [160fca1]
|
||||||
|
- Updated dependencies [f92e906]
|
||||||
|
- Updated dependencies [e721f17]
|
||||||
|
- Updated dependencies [727bde2]
|
||||||
|
- Updated dependencies [dff08ae]
|
||||||
|
- Updated dependencies [fc7b16f]
|
||||||
|
- Updated dependencies [fe8acc9]
|
||||||
|
- Updated dependencies [1823101]
|
||||||
|
- Updated dependencies [a652958]
|
||||||
|
- Updated dependencies [2f73db7]
|
||||||
|
- Updated dependencies [160fca1]
|
||||||
|
- Updated dependencies [12c7862]
|
||||||
|
- Updated dependencies [b41d425]
|
||||||
|
- @gitbook/react-openapi@1.0.0
|
||||||
|
- @gitbook/openapi-parser@1.0.0
|
||||||
|
- @gitbook/react-contentkit@0.6.0
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
|
||||||
|
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
|
||||||
|
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||||
|
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
|
||||||
|
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
|
||||||
|
- a2e5647: Fix the styling of site section tabs on smaller screens.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 076dc48: Fix expandable block anchore resolution
|
||||||
|
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
|
||||||
|
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
|
||||||
|
- 664debc: Add support for tint color
|
||||||
|
- 4d56f11: Update styling of search+ask modal
|
||||||
|
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
|
||||||
|
- 2f76712: Add breadcrumbs above page title
|
||||||
|
- 07cf835: Add scroll margin to the top when there are sections
|
||||||
|
- 5d72b35: Smoother tab transition for sections
|
||||||
|
- 7c71363: Don't adjust fallback font for mono font.
|
||||||
|
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
|
||||||
|
- 87eea73: Fix margin and image resolution of header logo
|
||||||
|
- aa2ed0f: Restyle hint blocks
|
||||||
|
- ffd3937: Fix security issue with image resizing that could be used for phishing
|
||||||
|
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
|
||||||
|
- c73e07d: Increase token max length to fix code not highlighted
|
||||||
|
- 3b3d6e2: Add icons to sections
|
||||||
|
- 1ed18c0: style: adds missing scalar css variables
|
||||||
|
- Updated dependencies [b7a5106]
|
||||||
|
- Updated dependencies [4771c78]
|
||||||
|
- Updated dependencies [ff50ac2]
|
||||||
|
- Updated dependencies [867481c]
|
||||||
|
- Updated dependencies [7ba67fd]
|
||||||
|
- Updated dependencies [a78c1ec]
|
||||||
|
- @gitbook/cache-do@0.1.1
|
||||||
|
- @gitbook/react-openapi@0.7.1
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e09f747: Revalidate change request cached content when pressing refresh button
|
||||||
|
- 2fa0851: Add navigation tabs for sections
|
||||||
|
- a4b63b8: Support resolution of new site URLs with sections
|
||||||
|
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||||
|
- e9b31a5: Unify section tab styles with page item styles
|
||||||
|
- f12a215: Add support for Norwegian language
|
||||||
|
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||||
|
- 1f24fe4: Add support for page icons
|
||||||
|
- cda08a9: Add support for searching results in a sections site
|
||||||
|
- b32e40c: Persist state of tabs and dynamically sync them based on title
|
||||||
|
- 15d2ee3: Show the caption for file blocks
|
||||||
|
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
|
||||||
|
- 07ea45b: Remove deprecated synced block from GitBook Open
|
||||||
|
- c3675fd: Added support for new Reusable Content block.
|
||||||
|
- 1f24fe4: Add support for icons style customization for sites
|
||||||
|
- 4c19014: Prevent search indexation for pages where it's configured as disabled
|
||||||
|
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||||
|
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||||
|
- 2c437f7: Fix linking to a tab itself
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||||
|
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||||
|
- a7066cc: Fix scroll position when navigating pages on mobile
|
||||||
|
- c754fc9: Add automatic color contrast in site header, restyle search button
|
||||||
|
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
|
||||||
|
- 6295881: Change dark mode shadow for multi-space search toolbar
|
||||||
|
- f89b31c: Upgrade the scalar api client package
|
||||||
|
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
|
||||||
|
- f885e88: Improve consistency of change request preview by removing cache-control on response
|
||||||
|
- 16e6171: Improve performances of loading pages with embeds by caching them
|
||||||
|
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||||
|
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
|
||||||
|
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||||
|
- 042b850: Automatically scroll to active item in TOC
|
||||||
|
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
|
||||||
|
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
|
||||||
|
- fe9e6c1: Update ogimage with new design
|
||||||
|
- 17f71ba: Use url hash to open Expandable and scroll to anchor
|
||||||
|
- 3c07e65: Fix margin for paragraphs in quote blocks
|
||||||
|
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||||
|
- f16560c: Include offset in calculations of whether scrollable element is in view
|
||||||
|
- 689f553: Fix inconsistent click area in table because of scroll indicator
|
||||||
|
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
|
||||||
|
- e914903: Synchronize response and response example tabs
|
||||||
|
- 0f990c7: Show definition title when visible in cards
|
||||||
|
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
|
||||||
|
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||||
|
- 3996110: Optimize images rendered in community ads
|
||||||
|
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||||
|
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||||
|
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||||
|
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||||
|
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||||
|
- cb782a7: Fix "ip" being passed to BSA for community ads
|
||||||
|
- a7af3ca: Improving the look and feel of new section tabs
|
||||||
|
- 0bf985a: Don't show hidden pages in the empty state of a page
|
||||||
|
- d6c28a0: Update header styling of sections, variant selector, and button links
|
||||||
|
|
||||||
|
- Change position of variant selector depending on context (next to logo or in table of contents)
|
||||||
|
- Update section tab styling and animation
|
||||||
|
- Make header buttons smaller with a new `medium` button size
|
||||||
|
|
||||||
|
- Updated dependencies [51fa3ab]
|
||||||
|
- Updated dependencies [9b8d519]
|
||||||
|
- Updated dependencies [cf3045a]
|
||||||
|
- Updated dependencies [f89b31c]
|
||||||
|
- Updated dependencies [d0f4860]
|
||||||
|
- Updated dependencies [ef9d012]
|
||||||
|
- Updated dependencies [094e9cd]
|
||||||
|
- Updated dependencies [636b868]
|
||||||
|
- Updated dependencies [56f5fa1]
|
||||||
|
- Updated dependencies [5c35f36]
|
||||||
|
- Updated dependencies [4247361]
|
||||||
|
- Updated dependencies [aa8c49e]
|
||||||
|
- Updated dependencies [e914903]
|
||||||
|
- Updated dependencies [4cbcc5b]
|
||||||
|
- Updated dependencies [0f1565c]
|
||||||
|
- Updated dependencies [237b703]
|
||||||
|
- Updated dependencies [51955da]
|
||||||
|
- Updated dependencies [a679e72]
|
||||||
|
- Updated dependencies [c079c3c]
|
||||||
|
- Updated dependencies [5c35f36]
|
||||||
|
- Updated dependencies [776bc31]
|
||||||
|
- @gitbook/react-openapi@0.7.0
|
||||||
|
- @gitbook/cache-do@0.1.0
|
||||||
|
- @gitbook/icons@0.1.0
|
||||||
|
- @gitbook/react-contentkit@0.5.1
|
||||||
|
- @gitbook/react-math@0.6.0
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
|
||||||
|
- Updated dependencies [709f1a1]
|
||||||
|
- Updated dependencies [ede2335]
|
||||||
|
- Updated dependencies [0426312]
|
||||||
|
- @gitbook/react-openapi@0.6.0
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3445db4]
|
||||||
|
- @gitbook/react-contentkit@0.5.0
|
||||||
|
- @gitbook/react-openapi@0.5.0
|
||||||
|
- @gitbook/react-math@0.5.0
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [24cd72e]
|
||||||
|
- @gitbook/react-contentkit@0.4.0
|
||||||
|
- @gitbook/react-math@0.4.0
|
||||||
|
- @gitbook/react-openapi@0.4.0
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- de747b7: Refactor the repository to be a proper monorepo and publish JS files on NPM instead of TypeScript files.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [de747b7]
|
||||||
|
- Updated dependencies [de747b7]
|
||||||
|
- @gitbook/react-contentkit@0.3.0
|
||||||
|
- @gitbook/react-openapi@0.3.0
|
||||||
|
- @gitbook/react-math@0.3.0
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { runTestCases, TestsCase, waitForCookiesDialog } from './util';
|
||||||
|
|
||||||
|
/** A list of test cases to run on the customers' docs sites. */
|
||||||
|
const testCases: TestsCase[] = [
|
||||||
|
{
|
||||||
|
name: 'Snyk',
|
||||||
|
baseUrl: 'https://docs.snyk.io',
|
||||||
|
tests: [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Nexthink',
|
||||||
|
baseUrl: 'https://docs.nexthink.com',
|
||||||
|
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||||
|
baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'jasons-tutorials.gitbook.io',
|
||||||
|
baseUrl: 'https://jasons-tutorials.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'faq.deltaemulator.com',
|
||||||
|
baseUrl: 'https://faq.deltaemulator.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.dify.ai',
|
||||||
|
baseUrl: 'https://docs.dify.ai',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seeddao.gitbook.io',
|
||||||
|
baseUrl: 'https://seeddao.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'faq.altstore.io',
|
||||||
|
baseUrl: 'https://faq.altstore.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'support.audacityteam.org',
|
||||||
|
baseUrl: 'https://support.audacityteam.org',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.gmgn.ai',
|
||||||
|
baseUrl: 'https://docs.gmgn.ai',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.spicychat.ai',
|
||||||
|
baseUrl: 'https://docs.spicychat.ai',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.portainer.io',
|
||||||
|
baseUrl: 'https://docs.portainer.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.chirptoken.io',
|
||||||
|
baseUrl: 'https://docs.chirptoken.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.dexscreener.com',
|
||||||
|
baseUrl: 'https://docs.dexscreener.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.pancakeswap.finance',
|
||||||
|
baseUrl: 'https://docs.pancakeswap.finance',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'book.character.ai',
|
||||||
|
baseUrl: 'https://book.character.ai',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.tradeonnova.io',
|
||||||
|
baseUrl: 'https://docs.tradeonnova.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'azcoiner.gitbook.io',
|
||||||
|
baseUrl: 'https://azcoiner.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.midas.app',
|
||||||
|
baseUrl: 'https://docs.midas.app',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.keeper.io',
|
||||||
|
baseUrl: 'https://docs.keeper.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'adiblar.gitbook.io',
|
||||||
|
baseUrl: 'https://adiblar.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.gradient.network',
|
||||||
|
baseUrl: 'https://docs.gradient.network',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mygate-network.gitbook.io',
|
||||||
|
baseUrl: 'https://mygate-network.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'treasurenft.gitbook.io',
|
||||||
|
baseUrl: 'https://treasurenft.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browndust2.gitbook.io',
|
||||||
|
baseUrl: 'https://browndust2.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'junookyo.gitbook.io',
|
||||||
|
baseUrl: 'https://junookyo.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'meshnet.nordvpn.com',
|
||||||
|
baseUrl: 'https://meshnet.nordvpn.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'manual.bubble.io',
|
||||||
|
baseUrl: 'https://manual.bubble.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.tickettool.xyz',
|
||||||
|
baseUrl: 'https://docs.tickettool.xyz',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wiki.redmodding.org',
|
||||||
|
baseUrl: 'https://wiki.redmodding.org',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.cherry-ai.com',
|
||||||
|
baseUrl: 'https://docs.cherry-ai.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.snyk.io',
|
||||||
|
baseUrl: 'https://docs.snyk.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.realapp.link',
|
||||||
|
baseUrl: 'https://docs.realapp.link',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.plaza.finance',
|
||||||
|
baseUrl: 'https://docs.plaza.finance',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.publicai.io',
|
||||||
|
baseUrl: 'https://docs.publicai.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hyperliquid.gitbook.io',
|
||||||
|
baseUrl: 'https://hyperliquid.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.umbraco.com',
|
||||||
|
baseUrl: 'https://docs.umbraco.com',
|
||||||
|
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sosovalue-white-paper.gitbook.io',
|
||||||
|
baseUrl: 'https://sosovalue-white-paper.gitbook.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.revrobotics.com',
|
||||||
|
baseUrl: 'https://docs.revrobotics.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'chartschool.stockcharts.com',
|
||||||
|
baseUrl: 'https://chartschool.stockcharts.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.soniclabs.com',
|
||||||
|
baseUrl: 'https://docs.soniclabs.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.meshchain.ai',
|
||||||
|
baseUrl: 'https://docs.meshchain.ai',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.thousandeyes.com',
|
||||||
|
baseUrl: 'https://docs.thousandeyes.com',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.raydium.io',
|
||||||
|
baseUrl: 'https://docs.raydium.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.solv.finance',
|
||||||
|
baseUrl: 'https://docs.solv.finance',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs.fluentbit.io',
|
||||||
|
baseUrl: 'https://docs.fluentbit.io',
|
||||||
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
runTestCases(testCases);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
|||||||
|
import { argosScreenshot } from '@argos-ci/playwright';
|
||||||
|
import {
|
||||||
|
CustomizationBackground,
|
||||||
|
CustomizationCorners,
|
||||||
|
CustomizationFont,
|
||||||
|
CustomizationHeaderItem,
|
||||||
|
CustomizationHeaderPreset,
|
||||||
|
CustomizationIconsStyle,
|
||||||
|
CustomizationLocale,
|
||||||
|
CustomizationSidebarBackgroundStyle,
|
||||||
|
CustomizationSidebarListStyle,
|
||||||
|
CustomizationTheme,
|
||||||
|
CustomizationThemedColor,
|
||||||
|
CustomizationThemeMode,
|
||||||
|
SiteCustomizationSettings,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import { test, expect, Page, BrowserContext } from '@playwright/test';
|
||||||
|
import deepMerge from 'deepmerge';
|
||||||
|
import rison from 'rison';
|
||||||
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
|
import { getContentTestURL } from '../tests/utils';
|
||||||
|
|
||||||
|
export interface Test {
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* URL to visit for testing.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
cookies?: Parameters<BrowserContext['addCookies']>[0];
|
||||||
|
/**
|
||||||
|
* Test to run
|
||||||
|
*/
|
||||||
|
run?: (page: Page) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* Whether the test should be fullscreened during testing.
|
||||||
|
*/
|
||||||
|
fullPage?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to take a screenshot of the test or set a threshold for the screenshot.
|
||||||
|
*/
|
||||||
|
screenshot?:
|
||||||
|
| false
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Screenshot threshold.
|
||||||
|
* From 0 to 1, where 0 is the most strict and 1 is the most permissive.
|
||||||
|
* @default 0.5
|
||||||
|
*/
|
||||||
|
threshold?: number;
|
||||||
|
/**
|
||||||
|
* Whether to wait for the table of contents to finish scrolling before taking the screenshot.
|
||||||
|
*/
|
||||||
|
waitForTOCScrolling?: boolean;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Whether to only run this test.
|
||||||
|
*/
|
||||||
|
only?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestsCase {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
tests: Array<Test>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allLocales: CustomizationLocale[] = [
|
||||||
|
CustomizationLocale.Fr,
|
||||||
|
CustomizationLocale.Es,
|
||||||
|
CustomizationLocale.Ja,
|
||||||
|
CustomizationLocale.Zh,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allThemeModes: CustomizationThemeMode[] = [
|
||||||
|
CustomizationThemeMode.Light,
|
||||||
|
CustomizationThemeMode.Dark,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allTintColors: Array<{
|
||||||
|
label: string;
|
||||||
|
value: CustomizationThemedColor | undefined;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: 'Off',
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
{ label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } },
|
||||||
|
{ label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allThemes: CustomizationTheme[] = [
|
||||||
|
CustomizationTheme.Clean,
|
||||||
|
CustomizationTheme.Muted,
|
||||||
|
CustomizationTheme.Bold,
|
||||||
|
CustomizationTheme.Gradient,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [
|
||||||
|
CustomizationHeaderPreset.Default,
|
||||||
|
CustomizationHeaderPreset.Bold,
|
||||||
|
CustomizationHeaderPreset.Contrast,
|
||||||
|
CustomizationHeaderPreset.Custom,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
|
||||||
|
CustomizationSidebarBackgroundStyle.Default,
|
||||||
|
CustomizationSidebarBackgroundStyle.Filled,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Common customization settings
|
||||||
|
|
||||||
|
export const headerLinks: CustomizationHeaderItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Secondary button',
|
||||||
|
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||||
|
style: 'button-secondary',
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Primary button',
|
||||||
|
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||||
|
style: 'button-primary',
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function waitForCookiesDialog(page: Page) {
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||||
|
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||||
|
const reject = dialog.getByRole('button', { name: 'Reject' });
|
||||||
|
await expect(accept).toBeVisible();
|
||||||
|
await expect(reject).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform test cases into Playwright tests and run it.
|
||||||
|
*/
|
||||||
|
export function runTestCases(testCases: TestsCase[]) {
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
test.describe(testCase.name, () => {
|
||||||
|
for (const testEntry of testCase.tests) {
|
||||||
|
const testFn = testEntry.only ? test.only : test;
|
||||||
|
testFn(testEntry.name, async ({ page, baseURL, context }) => {
|
||||||
|
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||||
|
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||||
|
if (testEntry.cookies) {
|
||||||
|
await context.addCookies(
|
||||||
|
testEntry.cookies.map((cookie) => ({
|
||||||
|
...cookie,
|
||||||
|
domain: new URL(url).host,
|
||||||
|
path: '/',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.goto(url);
|
||||||
|
if (testEntry.run) {
|
||||||
|
await testEntry.run(page);
|
||||||
|
}
|
||||||
|
const screenshotOptions = testEntry.screenshot;
|
||||||
|
if (screenshotOptions !== false) {
|
||||||
|
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
||||||
|
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||||
|
argosCSS: `
|
||||||
|
/* Hide Intercom */
|
||||||
|
.intercom-lightweight-app {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
threshold: screenshotOptions?.threshold ?? undefined,
|
||||||
|
fullPage: testEntry.fullPage ?? false,
|
||||||
|
beforeScreenshot: async () => {
|
||||||
|
await waitForIcons(page);
|
||||||
|
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||||
|
await waitForTOCScrolling(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a URL with customization settings.
|
||||||
|
*/
|
||||||
|
export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
|
||||||
|
// We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API.
|
||||||
|
// We can remove this once the migration to the new themes have been completed and the new theme styles are verified
|
||||||
|
// Map the theme preset (+ tint) to one of the new themes
|
||||||
|
const newTheme = (() => {
|
||||||
|
if (partial.styling?.theme) {
|
||||||
|
return partial.styling.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (partial.header?.preset) {
|
||||||
|
case CustomizationHeaderPreset.Bold:
|
||||||
|
case CustomizationHeaderPreset.Contrast:
|
||||||
|
case CustomizationHeaderPreset.Custom:
|
||||||
|
return CustomizationTheme.Bold;
|
||||||
|
|
||||||
|
case CustomizationHeaderPreset.None:
|
||||||
|
case CustomizationHeaderPreset.Default:
|
||||||
|
if (partial.styling?.tint) {
|
||||||
|
return CustomizationTheme.Muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomizationTheme.Clean;
|
||||||
|
default:
|
||||||
|
return CustomizationTheme.Clean;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default customization settings.
|
||||||
|
*
|
||||||
|
* The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = {
|
||||||
|
styling: {
|
||||||
|
theme: newTheme,
|
||||||
|
primaryColor: { light: '#346DDB', dark: '#346DDB' },
|
||||||
|
corners: CustomizationCorners.Rounded,
|
||||||
|
font: CustomizationFont.Inter,
|
||||||
|
background: CustomizationBackground.Plain,
|
||||||
|
icons: CustomizationIconsStyle.Regular,
|
||||||
|
sidebar: {
|
||||||
|
background: CustomizationSidebarBackgroundStyle.Default,
|
||||||
|
list: CustomizationSidebarListStyle.Default,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
internationalization: {
|
||||||
|
locale: CustomizationLocale.En,
|
||||||
|
},
|
||||||
|
favicon: {},
|
||||||
|
header: {
|
||||||
|
preset: CustomizationHeaderPreset.Default,
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
default: CustomizationThemeMode.Light,
|
||||||
|
toggeable: true,
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
feedback: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
aiSearch: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
advancedCustomization: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
showEditLink: false,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
trademark: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
privacyPolicy: {
|
||||||
|
url: 'https://www.gitbook.com/privacy',
|
||||||
|
},
|
||||||
|
socialPreview: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set('customization', encoded);
|
||||||
|
|
||||||
|
return `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for all icons present on the page to be loaded.
|
||||||
|
*/
|
||||||
|
async function waitForIcons(page: Page) {
|
||||||
|
await page.waitForFunction(async () => {
|
||||||
|
function loadImage(src: string) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||||
|
await Promise.all(
|
||||||
|
icons.map(async (icon) => {
|
||||||
|
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||||
|
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||||
|
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||||
|
const url = urlMatch ? urlMatch[1] : null;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('No mask-image');
|
||||||
|
}
|
||||||
|
await loadImage(url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for TOC to be correctly scrolled into view.
|
||||||
|
*/
|
||||||
|
async function waitForTOCScrolling(page: Page) {
|
||||||
|
const viewport = await page.viewportSize();
|
||||||
|
if (viewport && viewport.width >= 1024) {
|
||||||
|
const toc = page.getByTestId('table-of-contents');
|
||||||
|
await expect(toc).toBeVisible();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const tocScrollContainer = document.querySelector(
|
||||||
|
'[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]',
|
||||||
|
);
|
||||||
|
if (!tocScrollContainer) {
|
||||||
|
throw new Error('TOC scroll container not found');
|
||||||
|
}
|
||||||
|
tocScrollContainer.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ module.exports = withSentryConfig(
|
|||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||||
|
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
|
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
|
||||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||||
|
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||||
|
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||||
},
|
},
|
||||||
|
|
||||||
webpack(config, { dev, webpack }) {
|
webpack(config, { dev, webpack }) {
|
||||||
@@ -65,19 +69,15 @@ module.exports = withSentryConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
silent: true,
|
release: process.env.SENTRY_RELEASE,
|
||||||
org: process.env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG,
|
||||||
project: process.env.SENTRY_PROJECT,
|
project: process.env.SENTRY_PROJECT,
|
||||||
},
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
{
|
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
widenClientFileUpload: true,
|
widenClientFileUpload: true,
|
||||||
transpileClientSDK: false,
|
|
||||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||||
tunnelRoute: '/~gitbook/monitoring',
|
tunnelRoute: '/~gitbook/monitoring',
|
||||||
// Don't hide source maps from generated client bundles
|
|
||||||
hideSourceMaps: false,
|
|
||||||
disableLogger: true,
|
disableLogger: true,
|
||||||
automaticVercelMonitors: false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"name": "gitbook",
|
||||||
|
"version": "0.6.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"e2e": "playwright test e2e/internal.spec.ts",
|
||||||
|
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
||||||
|
"unit": "bun test {src,packages}",
|
||||||
|
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
|
||||||
|
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||||
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "^0.93.0",
|
||||||
|
"@gitbook/cache-do": "workspace:*",
|
||||||
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
|
"@gitbook/icons": "workspace:*",
|
||||||
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
|
"@gitbook/react-math": "workspace:*",
|
||||||
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@sentry/nextjs": "8.35.0",
|
||||||
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@upstash/redis": "^1.27.1",
|
||||||
|
"ajv": "^8.12.0",
|
||||||
|
"assert-never": "^1.2.1",
|
||||||
|
"bun-types": "^1.1.20",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"content-security-policy-merger": "^1.0.0",
|
||||||
|
"framer-motion": "^10.16.14",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"jsontoxml": "^1.0.1",
|
||||||
|
"katex": "^0.16.9",
|
||||||
|
"mathjax": "^3.2.2",
|
||||||
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
|
"memoizee": "^0.4.15",
|
||||||
|
"next": "14.2.23",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
|
"nuqs": "^2.2.3",
|
||||||
|
"object-hash": "^3.0.0",
|
||||||
|
"openapi-types": "^12.1.3",
|
||||||
|
"p-map": "^7.0.0",
|
||||||
|
"parse-cache-control": "^1.0.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-stringify": "^10.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.0",
|
||||||
|
"rison": "^0.1.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"shiki": "^1.27.2",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"tailwind-shades": "^1.1.2",
|
||||||
|
"unified": "^11.0.4",
|
||||||
|
"url-join": "^5.0.0",
|
||||||
|
"usehooks-ts": "^3.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@argos-ci/playwright": "^3.10.0",
|
||||||
|
"@cloudflare/next-on-pages": "1.13.7",
|
||||||
|
"vercel": "^39.3.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20241230.0",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/jsontoxml": "^1.0.5",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/object-hash": "^3.0.6",
|
||||||
|
"@types/parse-cache-control": "^1.0.4",
|
||||||
|
"@types/psi": "^4.1.6",
|
||||||
|
"@types/react": "18.3.13",
|
||||||
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@types/rison": "^0.0.9",
|
||||||
|
"autoprefixer": "^10",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"env-cmd": "^10.1.0",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "^14.2.5",
|
||||||
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"postcss": "^8",
|
||||||
|
"psi": "^4.1.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"ts-essentials": "^10.0.1",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -1,15 +1,13 @@
|
|||||||
import { init } from '@sentry/nextjs';
|
import * as Sentry from '@sentry/browser';
|
||||||
|
|
||||||
const dsn = process.env.SENTRY_DSN;
|
const dsn = process.env.SENTRY_DSN;
|
||||||
if (dsn) {
|
if (dsn) {
|
||||||
init({
|
Sentry.init({
|
||||||
debug: false,
|
|
||||||
dsn,
|
dsn,
|
||||||
|
release: process.env.SENTRY_RELEASE,
|
||||||
sampleRate: 0.1,
|
|
||||||
|
|
||||||
// Disable tracing as it creates additional requests in an env where subrequests are limited.
|
// Disable tracing as it creates additional requests in an env where subrequests are limited.
|
||||||
enableTracing: false,
|
tracesSampleRate: 0,
|
||||||
|
|
||||||
// Disable transactions as it creates additional requests in an env where subrequests are limited.
|
// Disable transactions as it creates additional requests in an env where subrequests are limited.
|
||||||
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
|
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
|
||||||
+36
-14
@@ -1,11 +1,12 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CURRENT_SIGNATURE_VERSION,
|
||||||
|
isSignatureVersion,
|
||||||
|
SignatureVersion,
|
||||||
verifyImageSignature,
|
verifyImageSignature,
|
||||||
resizeImage,
|
} from '@/lib/image-signatures';
|
||||||
CloudflareImageOptions,
|
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
|
||||||
checkIsSizableImageURL,
|
|
||||||
} from '@/lib/images';
|
|
||||||
import { parseImageAPIURL } from '@/lib/urls';
|
import { parseImageAPIURL } from '@/lib/urls';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
@@ -18,22 +19,21 @@ export const runtime = 'edge';
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
let urlParam = request.nextUrl.searchParams.get('url');
|
let urlParam = request.nextUrl.searchParams.get('url');
|
||||||
const signature = request.nextUrl.searchParams.get('sign');
|
const signature = request.nextUrl.searchParams.get('sign');
|
||||||
// The current signature algorithm sets version as 1, but we need to support the older version as well
|
|
||||||
// for previously generated content. In this case, we default to version 0.
|
|
||||||
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
|
|
||||||
if (!urlParam || !signature) {
|
if (!urlParam || !signature) {
|
||||||
return new Response('Missing url/sign parameters', { status: 400 });
|
return new Response('Missing url/sign parameters', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = parseImageAPIURL(urlParam);
|
const signatureVersion = parseSignatureVersion(request.nextUrl.searchParams.get('sv'));
|
||||||
|
if (!signatureVersion) {
|
||||||
// Prevent infinite loops
|
return new Response(`Invalid sv parameter`, { status: 400 });
|
||||||
if (url.includes('/~gitbook/image')) {
|
|
||||||
return new Response('Invalid url parameter', { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = parseImageAPIURL(urlParam);
|
||||||
|
|
||||||
// Check again if the image can be sized, even though we checked when rendering the Image component
|
// Check again if the image can be sized, even though we checked when rendering the Image component
|
||||||
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
|
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
|
||||||
|
// Also prevent infinite redirects.
|
||||||
if (!checkIsSizableImageURL(url)) {
|
if (!checkIsSizableImageURL(url)) {
|
||||||
return new Response('Invalid url parameter', { status: 400 });
|
return new Response('Invalid url parameter', { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signatureVersion !== CURRENT_SIGNATURE_VERSION) {
|
||||||
|
return NextResponse.redirect(url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
// Cloudflare-specific options are in the cf object.
|
// Cloudflare-specific options are in the cf object.
|
||||||
const options: CloudflareImageOptions = {
|
const options: CloudflareImageOptions = {
|
||||||
fit: 'scale-down',
|
fit: 'scale-down',
|
||||||
@@ -88,6 +92,24 @@ export async function GET(request: NextRequest) {
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Redirect to the original image if resizing fails
|
// Redirect to the original image if resizing fails
|
||||||
return Response.redirect(url, 302);
|
return NextResponse.redirect(url, 302);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the image signature version from a query param. Returns null if the version is invalid.
|
||||||
|
*/
|
||||||
|
function parseSignatureVersion(input: string | null): SignatureVersion | null {
|
||||||
|
// Before introducing the sv parameter, all signatures were generated with version 0.
|
||||||
|
if (!input) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the query param explicitly asks for a signature version.
|
||||||
|
if (isSignatureVersion(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise the version is invalid.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
-1
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
keys: result.keys,
|
|
||||||
stats: result.stats,
|
stats: result.stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ export function RocketLoaderDetector(props: { nonce: string }) {
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) {
|
if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) {
|
||||||
const alert = document.createElement('div');
|
const alert = document.createElement('div');
|
||||||
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 mt-8 mx-8';
|
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 mt-8 mx-8';
|
||||||
alert.innerHTML = \`
|
alert.innerHTML = \`
|
||||||
<strong>Error in site configuration:</strong>
|
<strong>Error in site configuration:</strong>
|
||||||
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
|
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
|
||||||
+4
-4
@@ -3,15 +3,15 @@
|
|||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useScrollToHash } from '@/components/hooks';
|
import { useScrollPage } from '@/components/hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client component to initialize interactivity for a page.
|
* Client component to initialize interactivity for a page.
|
||||||
*/
|
*/
|
||||||
export function PageClientLayout(props: {}) {
|
export function PageClientLayout(props: { withSections?: boolean }) {
|
||||||
// We use this hook in the page layout to ensure the elements for the blocks
|
// We use this hook in the page layout to ensure the elements for the blocks
|
||||||
// are rendered before we scroll to the hash.
|
// are rendered before we scroll to a hash or to the top of the page
|
||||||
useScrollToHash();
|
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
||||||
|
|
||||||
useStripFallbackQueryParam();
|
useStripFallbackQueryParam();
|
||||||
return null;
|
return null;
|
||||||
+1
-1
@@ -19,7 +19,7 @@ export default function PageSkeleton() {
|
|||||||
'lg:items-start',
|
'lg:items-start',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto')}>
|
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'page-full-width:mx-0')}>
|
||||||
<SkeletonHeading style={tcls('mb-8')} />
|
<SkeletonHeading style={tcls('mb-8')} />
|
||||||
<SkeletonParagraph style={tcls('mb-4')} />
|
<SkeletonParagraph style={tcls('mb-4')} />
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { TrackPageViewEvent } from '@/components/Insights';
|
||||||
|
import { getSpaceLanguage, t } from '@/intl/server';
|
||||||
|
import { getSiteData, getSpaceContentData } from '@/lib/api';
|
||||||
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const pointer = await getSiteContentPointer();
|
||||||
|
const [{ space }, { customization }] = await Promise.all([
|
||||||
|
getSpaceContentData(pointer, pointer.siteShareKey),
|
||||||
|
getSiteData(pointer),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const language = getSpaceLanguage(customization);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex-1',
|
||||||
|
'flex',
|
||||||
|
'flex-row',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'py-9',
|
||||||
|
'min-h-[calc(100vh-64px)] lg:min-h-fit',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={tcls('max-w-80')}>
|
||||||
|
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
|
||||||
|
{t(language, 'notfound_title')}
|
||||||
|
</h2>
|
||||||
|
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track the page not found as a page view */}
|
||||||
|
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+55
-38
@@ -5,34 +5,40 @@ import React from 'react';
|
|||||||
|
|
||||||
import { PageAside } from '@/components/PageAside';
|
import { PageAside } from '@/components/PageAside';
|
||||||
import { PageBody, PageCover } from '@/components/PageBody';
|
import { PageBody, PageCover } from '@/components/PageBody';
|
||||||
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
|
||||||
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
||||||
import { ContentRefContext } from '@/lib/references';
|
import { ContentRefContext } from '@/lib/references';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
import { PageClientLayout } from './PageClientLayout';
|
import { PageClientLayout } from './PageClientLayout';
|
||||||
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
|
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and render a page.
|
* Fetch and render a page.
|
||||||
*/
|
*/
|
||||||
export default async function Page(props: {
|
export default async function Page(props: {
|
||||||
params: PagePathParams;
|
params: Promise<PagePathParams>;
|
||||||
searchParams: { fallback?: string };
|
searchParams: Promise<{ fallback?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { params, searchParams } = props;
|
const { params: rawParams, searchParams: rawSearchParams } = props;
|
||||||
|
|
||||||
|
const params = await rawParams;
|
||||||
|
const searchParams = await rawSearchParams;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
content: contentPointer,
|
content: contentPointer,
|
||||||
contentTarget,
|
contentTarget,
|
||||||
|
sections,
|
||||||
space,
|
space,
|
||||||
parent,
|
site,
|
||||||
customization,
|
customization,
|
||||||
pages,
|
pages,
|
||||||
page,
|
page,
|
||||||
|
ancestors,
|
||||||
document,
|
document,
|
||||||
} = await getPageDataWithFallback({
|
} = await getPageDataWithFallback({
|
||||||
pagePathParams: params,
|
pagePathParams: params,
|
||||||
@@ -47,12 +53,12 @@ export default async function Page(props: {
|
|||||||
if (pathname !== rawPathname) {
|
if (pathname !== rawPathname) {
|
||||||
// If the pathname was not normalized, redirect to the normalized version
|
// If the pathname was not normalized, redirect to the normalized version
|
||||||
// before trying to resolve the page again
|
// before trying to resolve the page again
|
||||||
redirect(absoluteHref(pathname));
|
redirect(await getAbsoluteHref(pathname));
|
||||||
} else {
|
} else {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
} else if (getPagePath(pages, page) !== rawPathname) {
|
} else if (getPagePath(pages, page) !== rawPathname) {
|
||||||
redirect(pageHref(pages, page, linksContext));
|
redirect(await getPageHref(pages, page, linksContext));
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||||
@@ -64,54 +70,59 @@ export default async function Page(props: {
|
|||||||
const withPageFeedback = customization.feedback.enabled;
|
const withPageFeedback = customization.feedback.enabled;
|
||||||
|
|
||||||
const contentRefContext: ContentRefContext = {
|
const contentRefContext: ContentRefContext = {
|
||||||
|
siteContext: contentPointer,
|
||||||
space,
|
space,
|
||||||
revisionId: contentTarget.revisionId,
|
revisionId: contentTarget.revisionId,
|
||||||
pages,
|
pages,
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withSections = Boolean(sections && sections.list.length > 0);
|
||||||
|
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withFullPageCover && page.cover ? (
|
{withFullPageCover && page.cover ? (
|
||||||
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className={tcls('flex', 'flex-row')}>
|
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
|
||||||
<PageBody
|
<div className="flex flex-row-reverse justify-end grow">
|
||||||
space={space}
|
|
||||||
contentPointer={contentPointer}
|
|
||||||
contentTarget={contentTarget}
|
|
||||||
customization={customization}
|
|
||||||
context={contentRefContext}
|
|
||||||
page={page}
|
|
||||||
document={document}
|
|
||||||
withPageFeedback={
|
|
||||||
// Display the page feedback in the page footer if the aside is not visible
|
|
||||||
withPageFeedback && !page.layout.outline
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{page.layout.outline ? (
|
|
||||||
<PageAside
|
<PageAside
|
||||||
space={space}
|
space={space}
|
||||||
site={parent?.object === 'site' ? parent : undefined}
|
site={site}
|
||||||
customization={customization}
|
customization={customization}
|
||||||
page={page}
|
page={page}
|
||||||
document={document}
|
document={document}
|
||||||
withHeaderOffset={withTopHeader}
|
withHeaderOffset={headerOffset}
|
||||||
withFullPageCover={withFullPageCover}
|
withFullPageCover={withFullPageCover}
|
||||||
withPageFeedback={withPageFeedback}
|
withPageFeedback={withPageFeedback}
|
||||||
context={contentRefContext}
|
context={contentRefContext}
|
||||||
/>
|
/>
|
||||||
) : null}
|
<PageBody
|
||||||
|
space={space}
|
||||||
|
pointer={contentPointer}
|
||||||
|
contentTarget={contentTarget}
|
||||||
|
customization={customization}
|
||||||
|
context={contentRefContext}
|
||||||
|
page={page}
|
||||||
|
ancestors={ancestors}
|
||||||
|
document={document}
|
||||||
|
withPageFeedback={withPageFeedback}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<PageClientLayout />
|
<PageClientLayout withSections={withSections} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
|
export async function generateViewport({
|
||||||
const { customization } = await fetchPageData(params);
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<PagePathParams>;
|
||||||
|
}): Promise<Viewport> {
|
||||||
|
const { customization } = await fetchPageData(await params);
|
||||||
return {
|
return {
|
||||||
colorScheme: customization.themes.toggeable
|
colorScheme: customization.themes.toggeable
|
||||||
? customization.themes.default === CustomizationThemeMode.Dark
|
? customization.themes.default === CustomizationThemeMode.Dark
|
||||||
@@ -125,12 +136,12 @@ export async function generateMetadata({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: PagePathParams;
|
params: Promise<PagePathParams>;
|
||||||
searchParams: { fallback?: string };
|
searchParams: Promise<{ fallback?: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { space, pages, page, customization, parent } = await getPageDataWithFallback({
|
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
|
||||||
pagePathParams: params,
|
pagePathParams: await params,
|
||||||
searchParams,
|
searchParams: await searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -138,19 +149,25 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: [page.title, getContentTitle(space, customization, parent)]
|
title: [page.title, getContentTitle(space, customization, site ?? null)]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' | '),
|
.join(' | '),
|
||||||
description: page.description ?? '',
|
description: page.description ?? '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: absoluteHref(getPagePath(pages, page), true),
|
// Trim trailing slashes in canonical URL to match the redirect behavior
|
||||||
|
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [
|
images: [
|
||||||
customization.socialPreview.url ??
|
customization.socialPreview.url ??
|
||||||
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
|
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
robots:
|
||||||
|
(await isSpaceIndexable({ space, site: site ?? null })) &&
|
||||||
|
isPageIndexable(ancestors, page)
|
||||||
|
? 'index, follow'
|
||||||
|
: 'noindex, nofollow',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +189,7 @@ async function getPageDataWithFallback(args: {
|
|||||||
const rootPage = resolveFirstDocument(pages, []);
|
const rootPage = resolveFirstDocument(pages, []);
|
||||||
|
|
||||||
if (redirectOnFallback && rootPage?.page) {
|
if (redirectOnFallback && rootPage?.page) {
|
||||||
redirect(pageHref(pages, rootPage?.page));
|
redirect(await getPageHref(pages, rootPage?.page));
|
||||||
}
|
}
|
||||||
|
|
||||||
page = rootPage?.page;
|
page = rootPage?.page;
|
||||||
+27
-18
@@ -1,6 +1,7 @@
|
|||||||
import { CustomizationThemeMode } from '@gitbook/api';
|
import { CustomizationThemeMode } from '@gitbook/api';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
@@ -12,15 +13,16 @@ import { api } from '@/lib/api';
|
|||||||
import { assetsDomain } from '@/lib/assets';
|
import { assetsDomain } from '@/lib/assets';
|
||||||
import { buildVersion } from '@/lib/build';
|
import { buildVersion } from '@/lib/build';
|
||||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||||
import { absoluteHref, baseUrl } from '@/lib/links';
|
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
|
||||||
import { shouldIndexSpace } from '@/lib/seo';
|
import { isSpaceIndexable } from '@/lib/seo';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
import { ClientContexts } from './ClientContexts';
|
import { ClientContexts } from './ClientContexts';
|
||||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||||
import { fetchSpaceData } from '../fetch';
|
import { fetchContentData } from '../fetch';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout when rendering the content.
|
* Layout when rendering the content.
|
||||||
@@ -28,20 +30,22 @@ export const runtime = 'edge';
|
|||||||
export default async function ContentLayout(props: { children: React.ReactNode }) {
|
export default async function ContentLayout(props: { children: React.ReactNode }) {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const nonce = getContentSecurityPolicyNonce();
|
const nonce = await getContentSecurityPolicyNonce();
|
||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
space,
|
space,
|
||||||
contentTarget,
|
contentTarget,
|
||||||
customization,
|
customization,
|
||||||
pages,
|
pages,
|
||||||
parent,
|
site,
|
||||||
spaces,
|
spaces,
|
||||||
ancestors,
|
ancestors,
|
||||||
scripts,
|
scripts,
|
||||||
} = await fetchSpaceData();
|
sections,
|
||||||
|
} = await fetchContentData();
|
||||||
|
|
||||||
ReactDOM.preconnect(api().endpoint);
|
const apiCtx = await api();
|
||||||
|
ReactDOM.preconnect(apiCtx.client.endpoint);
|
||||||
if (assetsDomain) {
|
if (assetsDomain) {
|
||||||
ReactDOM.preconnect(assetsDomain);
|
ReactDOM.preconnect(assetsDomain);
|
||||||
}
|
}
|
||||||
@@ -53,19 +57,23 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryStringTheme = await getQueryStringTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NuqsAdapter>
|
||||||
<ClientContexts
|
<ClientContexts
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
forcedTheme={
|
forcedTheme={
|
||||||
getQueryStringTheme() ??
|
queryStringTheme ??
|
||||||
(customization.themes.toggeable ? undefined : customization.themes.default)
|
(customization.themes.toggeable ? undefined : customization.themes.default)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SpaceLayout
|
<SpaceLayout
|
||||||
space={space}
|
space={space}
|
||||||
contentTarget={contentTarget}
|
contentTarget={contentTarget}
|
||||||
parent={parent}
|
site={site}
|
||||||
spaces={spaces}
|
spaces={spaces}
|
||||||
|
sections={sections}
|
||||||
customization={customization}
|
customization={customization}
|
||||||
pages={pages}
|
pages={pages}
|
||||||
ancestors={ancestors}
|
ancestors={ancestors}
|
||||||
@@ -93,11 +101,12 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
|||||||
|
|
||||||
<AdminToolbar space={space} content={content} />
|
<AdminToolbar space={space} content={content} />
|
||||||
</ClientContexts>
|
</ClientContexts>
|
||||||
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(): Promise<Viewport> {
|
export async function generateViewport(): Promise<Viewport> {
|
||||||
const { customization } = await fetchSpaceData();
|
const { customization } = await fetchContentData();
|
||||||
return {
|
return {
|
||||||
colorScheme: customization.themes.toggeable
|
colorScheme: customization.themes.toggeable
|
||||||
? customization.themes.default === CustomizationThemeMode.Dark
|
? customization.themes.default === CustomizationThemeMode.Dark
|
||||||
@@ -108,40 +117,40 @@ export async function generateViewport(): Promise<Viewport> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const { space, parent, customization } = await fetchSpaceData();
|
const { space, site, customization } = await fetchContentData();
|
||||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: getContentTitle(space, customization, parent),
|
title: getContentTitle(space, customization, site),
|
||||||
generator: `GitBook (${buildVersion()})`,
|
generator: `GitBook (${buildVersion()})`,
|
||||||
metadataBase: new URL(baseUrl()),
|
metadataBase: new URL(await getBaseUrl()),
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
url:
|
url:
|
||||||
customIcon?.light ??
|
customIcon?.light ??
|
||||||
absoluteHref('~gitbook/icon?size=small&theme=light', true),
|
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: light)',
|
media: '(prefers-color-scheme: light)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url:
|
url:
|
||||||
customIcon?.dark ??
|
customIcon?.dark ??
|
||||||
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
|
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
media: '(prefers-color-scheme: dark)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
|
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For preview, the theme can be set via query string (?theme=light).
|
* For preview, the theme can be set via query string (?theme=light).
|
||||||
*/
|
*/
|
||||||
function getQueryStringTheme() {
|
async function getQueryStringTheme() {
|
||||||
const headersList = headers();
|
const headersList = await headers();
|
||||||
const queryStringTheme = headersList.get('x-gitbook-theme');
|
const queryStringTheme = headersList.get('x-gitbook-theme');
|
||||||
if (!queryStringTheme) {
|
if (!queryStringTheme) {
|
||||||
return null;
|
return null;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import { ListItem, Paragraph, Root, RootContent } from 'mdast';
|
||||||
|
import { toMarkdown } from 'mdast-util-to-markdown';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
|
||||||
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
|
import { getPagePath } from '@/lib/pages';
|
||||||
|
import { joinPath } from '@/lib/paths';
|
||||||
|
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
import { getIndexablePages } from '@/lib/sitemap';
|
||||||
|
import { getSiteStructureSections } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a llms.txt file for the site.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const pointer = await getSiteContentPointer();
|
||||||
|
|
||||||
|
const { structure: siteStructure, site } = await getPublishedContentSite({
|
||||||
|
organizationId: pointer.organizationId,
|
||||||
|
siteId: pointer.siteId,
|
||||||
|
siteShareKey: pointer.siteShareKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||||
|
return new Response('llms.txt is only served from the root of the site', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree: Root = {
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
depth: 1,
|
||||||
|
children: [{ type: 'text', value: site.title }],
|
||||||
|
},
|
||||||
|
...(await getNodesFromSiteStructure(siteStructure)),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
toMarkdown(tree, {
|
||||||
|
bullet: '-',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MDAST nodes from site structure.
|
||||||
|
*/
|
||||||
|
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
|
||||||
|
switch (siteStructure.type) {
|
||||||
|
case 'sections':
|
||||||
|
return getNodesFromSections(getSiteStructureSections(siteStructure));
|
||||||
|
case 'siteSpaces':
|
||||||
|
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
|
||||||
|
default:
|
||||||
|
assertNever(siteStructure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MDAST nodes from site sections.
|
||||||
|
*/
|
||||||
|
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
|
||||||
|
const all = await Promise.all(
|
||||||
|
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
||||||
|
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
|
||||||
|
heading: false,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
depth: 2,
|
||||||
|
children: [{ type: 'text', value: siteSection.title }],
|
||||||
|
},
|
||||||
|
...siteSpaceNodes,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return all.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MDAST nodes from site spaces.
|
||||||
|
*/
|
||||||
|
async function getNodesFromSiteSpaces(
|
||||||
|
siteSpaces: SiteSpace[],
|
||||||
|
options: {
|
||||||
|
/**
|
||||||
|
* Includes a heading for each site space.
|
||||||
|
*/
|
||||||
|
heading?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<RootContent[]> {
|
||||||
|
const all = await Promise.all(
|
||||||
|
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
|
||||||
|
const siteSpaceUrl = siteSpace.urls.published;
|
||||||
|
if (!siteSpaceUrl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
|
||||||
|
metadata: false,
|
||||||
|
});
|
||||||
|
const pages = getIndexablePages(rootPages);
|
||||||
|
const listChildren = await Promise.all(
|
||||||
|
pages.map(async ({ page }): Promise<ListItem> => {
|
||||||
|
const url = await getAbsoluteHref(
|
||||||
|
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const children: Paragraph['children'] = [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
url,
|
||||||
|
children: [{ type: 'text', value: page.title }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (page.description) {
|
||||||
|
children.push({ type: 'text', value: `: ${page.description}` });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'listItem',
|
||||||
|
children: [{ type: 'paragraph', children }],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const nodes: RootContent[] = [];
|
||||||
|
if (options.heading) {
|
||||||
|
nodes.push({
|
||||||
|
type: 'heading',
|
||||||
|
depth: 2,
|
||||||
|
children: [{ type: 'text', value: siteSpace.title }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nodes.push({
|
||||||
|
type: 'list',
|
||||||
|
spread: false,
|
||||||
|
children: listChildren,
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return all.flat();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { getSpace, getSite } from '@/lib/api';
|
||||||
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
import { isSpaceIndexable } from '@/lib/seo';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a robots.txt for the current space.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const pointer = await getSiteContentPointer();
|
||||||
|
const [site, space] = await Promise.all([
|
||||||
|
getSite(pointer.organizationId, pointer.siteId),
|
||||||
|
getSpace(pointer.spaceId, pointer.siteShareKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`User-agent: *`,
|
||||||
|
'Disallow: /~gitbook/',
|
||||||
|
...((await isSpaceIndexable({ space, site }))
|
||||||
|
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
|
||||||
|
: [`Disallow: /`]),
|
||||||
|
];
|
||||||
|
const content = lines.join('\n');
|
||||||
|
|
||||||
|
return new Response(content, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import jsontoxml from 'jsontoxml';
|
||||||
|
|
||||||
|
import { getRevisionPages, getSpace } from '@/lib/api';
|
||||||
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
|
import { getPagePath } from '@/lib/pages';
|
||||||
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
import { getIndexablePages } from '@/lib/sitemap';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sitemap.xml for the current section / space.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const pointer = await getSiteContentPointer();
|
||||||
|
|
||||||
|
const revisionId =
|
||||||
|
pointer.revisionId ?? (await getSpace(pointer.spaceId, pointer.siteShareKey)).revision;
|
||||||
|
|
||||||
|
const rootPages = await getRevisionPages(pointer.spaceId, revisionId, { metadata: false });
|
||||||
|
|
||||||
|
const pages = getIndexablePages(rootPages);
|
||||||
|
|
||||||
|
const urls = await Promise.all(
|
||||||
|
pages.map(async ({ page, depth }) => {
|
||||||
|
// Decay priority with depth
|
||||||
|
const priority = Math.pow(2, -0.25 * depth);
|
||||||
|
// Normalize to keep 2 decimals
|
||||||
|
const normalizedPriority = Math.floor(100 * priority) / 100;
|
||||||
|
const lastModified = page.updatedAt || page.createdAt;
|
||||||
|
|
||||||
|
const url: { loc: string; priority: number; lastmod?: string } = {
|
||||||
|
priority: normalizedPriority,
|
||||||
|
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastModified) {
|
||||||
|
url.lastmod = new Date(lastModified).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const xml = jsontoxml(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'urlset',
|
||||||
|
children: urls,
|
||||||
|
attrs: {
|
||||||
|
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
xmlHeader: true,
|
||||||
|
prettyPrint: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import jsontoxml from 'jsontoxml';
|
||||||
|
|
||||||
|
import { getPublishedContentSite } from '@/lib/api';
|
||||||
|
import { joinPath } from '@/lib/paths';
|
||||||
|
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
|
import { getSiteStructureSections } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a root sitemap that point to all sitemap-pages.xml.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const pointer = await getSiteContentPointer();
|
||||||
|
|
||||||
|
const { structure: siteStructure } = await getPublishedContentSite({
|
||||||
|
organizationId: pointer.organizationId,
|
||||||
|
siteId: pointer.siteId,
|
||||||
|
siteShareKey: pointer.siteShareKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||||
|
return new Response('Root sitemap is only served from the root of the site', {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = await getUrlsFromSiteStructure(siteStructure);
|
||||||
|
|
||||||
|
const xml = jsontoxml(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'sitemapindex',
|
||||||
|
children: urls.map((url) => ({
|
||||||
|
name: 'sitemap',
|
||||||
|
children: [{ name: 'loc', text: url }],
|
||||||
|
})),
|
||||||
|
attrs: {
|
||||||
|
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
xmlHeader: true,
|
||||||
|
prettyPrint: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Sitemap URLs from site structure.
|
||||||
|
*/
|
||||||
|
async function getUrlsFromSiteStructure(siteStructure: SiteStructure): Promise<string[]> {
|
||||||
|
switch (siteStructure.type) {
|
||||||
|
case 'sections':
|
||||||
|
return getUrlsFromSiteSections(getSiteStructureSections(siteStructure));
|
||||||
|
case 'siteSpaces':
|
||||||
|
return getUrlsFromSiteSpaces(siteStructure.structure);
|
||||||
|
default:
|
||||||
|
assertNever(siteStructure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Sitemap URLs from site sections.
|
||||||
|
*/
|
||||||
|
async function getUrlsFromSiteSections(siteSections: SiteSection[]): Promise<string[]> {
|
||||||
|
const urls = await Promise.all(
|
||||||
|
siteSections.map(async (siteSection) => getUrlsFromSiteSpaces(siteSection.siteSpaces), []),
|
||||||
|
);
|
||||||
|
return urls.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Sitemap URLs from site spaces.
|
||||||
|
*/
|
||||||
|
async function getUrlsFromSiteSpaces(siteSpaces: SiteSpace[]): Promise<string[]> {
|
||||||
|
const urls = await Promise.all(
|
||||||
|
siteSpaces.map(async (siteSpace) => {
|
||||||
|
if (!siteSpace.urls.published) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = new URL(siteSpace.urls.published);
|
||||||
|
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
|
||||||
|
return url.toString();
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
return urls.filter(filterOutNullable);
|
||||||
|
}
|
||||||
+10
-22
@@ -1,18 +1,11 @@
|
|||||||
import { ContentVisibility } from '@gitbook/api';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getContentPointer } from '@/app/(space)/fetch';
|
import { getSite, getSiteData, getSpace } from '@/lib/api';
|
||||||
import {
|
|
||||||
getCollection,
|
|
||||||
getCurrentSiteCustomization,
|
|
||||||
getSite,
|
|
||||||
getSpace,
|
|
||||||
getSpaceCustomization,
|
|
||||||
} from '@/lib/api';
|
|
||||||
import { getEmojiForCode } from '@/lib/emojis';
|
import { getEmojiForCode } from '@/lib/emojis';
|
||||||
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -42,25 +35,20 @@ export async function GET(req: NextRequest) {
|
|||||||
const options = getOptions(req.url);
|
const options = getOptions(req.url);
|
||||||
const size = SIZES[options.size];
|
const size = SIZES[options.size];
|
||||||
|
|
||||||
const pointer = getContentPointer();
|
const pointer = await getSiteContentPointer();
|
||||||
const spaceId = pointer.spaceId;
|
const spaceId = pointer.spaceId;
|
||||||
|
|
||||||
const [space, customization] = await Promise.all([
|
const [space, { customization }] = await Promise.all([
|
||||||
getSpace(spaceId),
|
getSpace(spaceId, pointer.siteShareKey),
|
||||||
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
|
getSiteData(pointer),
|
||||||
]);
|
]);
|
||||||
const parent =
|
const site = await getSite(pointer.organizationId, pointer.siteId);
|
||||||
'siteId' in pointer
|
const contentTitle = getContentTitle(space, customization, site);
|
||||||
? await getSite(pointer.organizationId, pointer.siteId)
|
|
||||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
|
||||||
? await getCollection(space.parent)
|
|
||||||
: null;
|
|
||||||
const contentTitle = getContentTitle(space, customization, parent);
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<div
|
<div
|
||||||
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-gray-900', size.boxStyle)}
|
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -74,7 +62,7 @@ export async function GET(req: NextRequest) {
|
|||||||
size.textSize,
|
size.textSize,
|
||||||
'font-bold',
|
'font-bold',
|
||||||
'tracking-tight',
|
'tracking-tight',
|
||||||
options.theme === 'light' ? 'text-gray-900' : 'text-white',
|
options.theme === 'light' ? 'text-black' : 'text-white',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{'emoji' in customization.favicon
|
{'emoji' in customization.favicon
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { googleFontsMap } from '@/fonts';
|
||||||
|
import { colorContrast } from '@/lib/colors';
|
||||||
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
async function loadGoogleFont(input: { fontFamily: string; text: string; weight: 400 | 700 }) {
|
||||||
|
const { fontFamily, text, weight } = input;
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('https://fonts.googleapis.com/css2');
|
||||||
|
url.searchParams.set('family', `${fontFamily}:wght@${weight}`);
|
||||||
|
url.searchParams.set('text', text);
|
||||||
|
|
||||||
|
const result = await fetch(url.href);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await result.text();
|
||||||
|
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
|
||||||
|
const resourceUrl = resource ? resource[1] : null;
|
||||||
|
|
||||||
|
if (resourceUrl) {
|
||||||
|
const response = await fetch(resourceUrl);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
return {
|
||||||
|
name: fontFamily,
|
||||||
|
data,
|
||||||
|
style: 'normal' as const,
|
||||||
|
weight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If for some reason we can't load the font, we'll just use the default one
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the OpenGraph image for a space.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
|
||||||
|
const { space, page, customization, site } = await fetchPageData(await params);
|
||||||
|
|
||||||
|
// If user configured a custom social preview, we redirect to it.
|
||||||
|
if (customization.socialPreview.url) {
|
||||||
|
redirect(customization.socialPreview.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute all text to load only the necessary fonts
|
||||||
|
const contentTitle = customization.header.logo
|
||||||
|
? ''
|
||||||
|
: getContentTitle(space, customization, site ?? null);
|
||||||
|
const pageTitle = page
|
||||||
|
? page.title.length > 64
|
||||||
|
? page.title.slice(0, 64) + '...'
|
||||||
|
: page.title
|
||||||
|
: 'Not found';
|
||||||
|
const pageDescription =
|
||||||
|
page?.description && page?.title.length <= 64
|
||||||
|
? page.description.length > 164
|
||||||
|
? page.description.slice(0, 164) + '...'
|
||||||
|
: page.description
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const fontFamily = googleFontsMap[customization.styling.font] ?? 'Inter';
|
||||||
|
|
||||||
|
const regularText = pageDescription;
|
||||||
|
const boldText = `${contentTitle}${pageTitle}`;
|
||||||
|
|
||||||
|
const fonts = (
|
||||||
|
await Promise.all([
|
||||||
|
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
|
||||||
|
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
|
||||||
|
])
|
||||||
|
).filter(filterOutNullable);
|
||||||
|
|
||||||
|
const theme = customization.themes.default;
|
||||||
|
const useLightTheme = theme === 'light';
|
||||||
|
|
||||||
|
// We have no access to CSS variables, so we'll have to hardcode some values
|
||||||
|
const baseColors = { light: '#ffffff', dark: '#111827' };
|
||||||
|
|
||||||
|
let colors = {
|
||||||
|
background: baseColors[theme],
|
||||||
|
gradient: customization.styling.primaryColor[theme],
|
||||||
|
title: customization.styling.primaryColor[theme],
|
||||||
|
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
|
||||||
|
};
|
||||||
|
|
||||||
|
const [gridWhite, gridBlack] = await Promise.all([
|
||||||
|
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
|
||||||
|
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
||||||
|
|
||||||
|
switch (customization.header.preset) {
|
||||||
|
case CustomizationHeaderPreset.Custom:
|
||||||
|
colors = {
|
||||||
|
background: customization.header.backgroundColor?.[theme] || colors.background,
|
||||||
|
gradient: customization.header.linkColor?.[theme] || colors.gradient,
|
||||||
|
title: customization.header.linkColor?.[theme] || colors.title,
|
||||||
|
body: colorContrast(
|
||||||
|
customization.header.backgroundColor?.[theme] || colors.background,
|
||||||
|
[baseColors.light, baseColors.dark],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CustomizationHeaderPreset.Bold:
|
||||||
|
colors = {
|
||||||
|
background: customization.styling.primaryColor[theme],
|
||||||
|
gradient: colorContrast(customization.styling.primaryColor[theme], [
|
||||||
|
baseColors.light,
|
||||||
|
baseColors.dark,
|
||||||
|
]),
|
||||||
|
title: colorContrast(customization.styling.primaryColor[theme], [
|
||||||
|
baseColors.light,
|
||||||
|
baseColors.dark,
|
||||||
|
]),
|
||||||
|
body: colorContrast(customization.styling.primaryColor[theme], [
|
||||||
|
baseColors.light,
|
||||||
|
baseColors.dark,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favicon = await (async () => {
|
||||||
|
if ('icon' in customization.favicon)
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={customization.favicon.icon[theme]}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
tw="mr-4"
|
||||||
|
alt="Icon"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if ('emoji' in customization.favicon)
|
||||||
|
return (
|
||||||
|
<span tw="text-4xl mr-4">
|
||||||
|
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const src = await getAbsoluteHref(
|
||||||
|
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
tw={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`}
|
||||||
|
style={{
|
||||||
|
fontFamily,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Gradient */}
|
||||||
|
<div
|
||||||
|
tw="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
{customization.header.logo ? (
|
||||||
|
<img
|
||||||
|
alt="Logo"
|
||||||
|
height={60}
|
||||||
|
src={
|
||||||
|
useLightTheme
|
||||||
|
? customization.header.logo.light
|
||||||
|
: customization.header.logo.dark
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div tw="flex">
|
||||||
|
{favicon}
|
||||||
|
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title and description */}
|
||||||
|
<div tw="flex flex-col">
|
||||||
|
<h1
|
||||||
|
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
|
||||||
|
>
|
||||||
|
{pageTitle}
|
||||||
|
</h1>
|
||||||
|
{pageDescription ? (
|
||||||
|
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: fonts.length ? fonts : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { captureException } from '@sentry/nextjs';
|
import { captureException, withScope } from '@sentry/nextjs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
@@ -15,7 +15,12 @@ export default function ErrorPage(props: {
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
withScope((scope) => {
|
||||||
|
if ('_componentStack' in error && error._componentStack) {
|
||||||
|
scope.setExtra('componentStack', error._componentStack);
|
||||||
|
}
|
||||||
captureException(error);
|
captureException(error);
|
||||||
|
});
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { RevisionPage } from '@gitbook/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRevisionPageByPath,
|
||||||
|
getSpaceContentData,
|
||||||
|
getSiteData,
|
||||||
|
getSiteRedirectBySource,
|
||||||
|
getPageDocument,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||||
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
|
|
||||||
|
export interface PagePathParams {
|
||||||
|
pathname?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageIdParams {
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the data needed to render the content layout.
|
||||||
|
*/
|
||||||
|
export async function fetchContentData() {
|
||||||
|
const content = await getSiteContentPointer();
|
||||||
|
|
||||||
|
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
|
||||||
|
await Promise.all([
|
||||||
|
getSpaceContentData(content, content.siteShareKey),
|
||||||
|
getSiteData(content),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// we grab the space attached to the parent as it contains overriden customizations
|
||||||
|
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
contentTarget,
|
||||||
|
space: spaceRelativeToParent ?? space,
|
||||||
|
pages,
|
||||||
|
site,
|
||||||
|
sections,
|
||||||
|
spaces,
|
||||||
|
shareKey: content.siteShareKey,
|
||||||
|
customization,
|
||||||
|
scripts,
|
||||||
|
ancestors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the data needed to render the content.
|
||||||
|
* Optimized to fetch in parallel as much as possible.
|
||||||
|
*/
|
||||||
|
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||||
|
const contentData = await fetchContentData();
|
||||||
|
|
||||||
|
const page = await resolvePage({
|
||||||
|
organizationId: contentData.space.organization,
|
||||||
|
siteId: contentData.site.id,
|
||||||
|
spaceId: contentData.contentTarget.spaceId,
|
||||||
|
revisionId: contentData.contentTarget.revisionId,
|
||||||
|
pages: contentData.pages,
|
||||||
|
shareKey: contentData.shareKey,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
const document = page ? await getPageDocument(contentData.space.id, page.page) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...contentData,
|
||||||
|
...page,
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a page from the params.
|
||||||
|
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||||
|
*/
|
||||||
|
async function resolvePage(input: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
shareKey: string | undefined;
|
||||||
|
pages: RevisionPage[];
|
||||||
|
params: PagePathParams | PageIdParams;
|
||||||
|
}) {
|
||||||
|
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
|
||||||
|
|
||||||
|
if ('pageId' in params) {
|
||||||
|
return resolvePageId(pages, params.pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPathname = getPathnameParam(params);
|
||||||
|
const pathname = normalizePathname(rawPathname);
|
||||||
|
|
||||||
|
// When resolving a page, we use the lowercased pathname
|
||||||
|
const page = resolvePagePath(pages, pathname);
|
||||||
|
if (page) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||||
|
if (rawPathname.length <= 512) {
|
||||||
|
// If page can't be found, we try with the API, in case we have a redirect at space level.
|
||||||
|
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||||
|
// The page rendering will take care of redirecting to a normalized pathname.
|
||||||
|
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
|
||||||
|
if (resolved) {
|
||||||
|
return resolvePageId(pages, resolved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
|
||||||
|
const resolvedSiteRedirect = await getSiteRedirectBySource({
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
|
||||||
|
siteShareKey: input.shareKey,
|
||||||
|
});
|
||||||
|
if (resolvedSiteRedirect) {
|
||||||
|
return redirect(resolvedSiteRedirect.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the page path from the params.
|
||||||
|
*/
|
||||||
|
export function getPathnameParam(params: PagePathParams): string {
|
||||||
|
const { pathname } = params;
|
||||||
|
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the URL pathname into the format used in the revision page path.
|
||||||
|
*/
|
||||||
|
export function normalizePathname(pathname: string) {
|
||||||
|
return pathname.toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
|
import { getSiteData } 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 = await getSiteContentPointer();
|
||||||
|
const { customization } = await getSiteData(pointer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertTriangle } from '@geist-ui/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useScrollActiveId } from '@/components/hooks';
|
import { useScrollActiveId } from '@/components/hooks';
|
||||||
@@ -105,7 +105,10 @@ export function PageControlButtons(props: {
|
|||||||
'max-w-sm',
|
'max-w-sm',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AlertTriangle className={tcls('size-6', 'mr-3', 'mt-1')} />{' '}
|
<Icon
|
||||||
|
icon="triangle-exclamation"
|
||||||
|
className={tcls('size-6', 'mr-3', 'mt-1')}
|
||||||
|
/>{' '}
|
||||||
<div>
|
<div>
|
||||||
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
|
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -130,7 +133,7 @@ export function PageControlButtons(props: {
|
|||||||
'items-center',
|
'items-center',
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'text-lg',
|
'text-lg',
|
||||||
'text-dark/6',
|
'text-tint',
|
||||||
'px-6',
|
'px-6',
|
||||||
'py-2',
|
'py-2',
|
||||||
'bg-slate-100',
|
'bg-slate-100',
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { SpaceIntegrationScript } from '@gitbook/api';
|
||||||
|
|
||||||
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
|
import { getSiteData, getSpaceCustomization } from '@/lib/api';
|
||||||
|
|
||||||
|
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout to be used for the site root. It fetches the customization data for the
|
||||||
|
* site or space and initializes the CustomizationRootLayout with it.
|
||||||
|
*/
|
||||||
|
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const pointer = await getSiteOrSpacePointerForPDF();
|
||||||
|
const { customization } = await ('siteId' in pointer
|
||||||
|
? getSiteData(pointer)
|
||||||
|
: getSpaceLayoutData());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the layout data about a space at once.
|
||||||
|
*/
|
||||||
|
async function getSpaceLayoutData() {
|
||||||
|
const [{ customization }, scripts] = await Promise.all([
|
||||||
|
getSpaceCustomization(),
|
||||||
|
[] as SpaceIntegrationScript[],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customization,
|
||||||
|
scripts,
|
||||||
|
};
|
||||||
|
}
|
||||||
+40
-40
@@ -1,19 +1,18 @@
|
|||||||
import { ArrowLeft, Printer } from '@geist-ui/icons';
|
|
||||||
import {
|
import {
|
||||||
CustomizationSettings,
|
CustomizationSettings,
|
||||||
Revision,
|
Revision,
|
||||||
RevisionPageDocument,
|
RevisionPageDocument,
|
||||||
RevisionPageGroup,
|
RevisionPageGroup,
|
||||||
RevisionPageLink,
|
|
||||||
SiteCustomizationSettings,
|
SiteCustomizationSettings,
|
||||||
|
SiteInsightsTrademarkPlacement,
|
||||||
Space,
|
Space,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { getContentPointer } from '@/app/(space)/fetch';
|
import { DocumentView } from '@/components/DocumentView';
|
||||||
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
|
|
||||||
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
||||||
import { PolymorphicComponentProp } from '@/components/utils/types';
|
import { PolymorphicComponentProp } from '@/components/utils/types';
|
||||||
import { getSpaceLanguage } from '@/intl/server';
|
import { getSpaceLanguage } from '@/intl/server';
|
||||||
@@ -23,9 +22,10 @@ import {
|
|||||||
getSpace,
|
getSpace,
|
||||||
getSpaceCustomization,
|
getSpaceCustomization,
|
||||||
getSpaceContentData,
|
getSpaceContentData,
|
||||||
getCurrentSiteCustomization,
|
getSiteData,
|
||||||
|
getPageDocument,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
|
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
|
||||||
import { resolvePageId } from '@/lib/pages';
|
import { resolvePageId } from '@/lib/pages';
|
||||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
@@ -33,19 +33,19 @@ import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
|
|||||||
|
|
||||||
import './pdf.css';
|
import './pdf.css';
|
||||||
import { PageControlButtons } from './PageControlButtons';
|
import { PageControlButtons } from './PageControlButtons';
|
||||||
|
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||||
import { PrintButton } from './PrintButton';
|
import { PrintButton } from './PrintButton';
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 100;
|
const DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const contentPointer = getContentPointer();
|
const pointer = await getSiteOrSpacePointerForPDF();
|
||||||
const [space, customization] = await Promise.all([
|
const [space, { customization }] = await Promise.all([
|
||||||
getSpace(contentPointer.spaceId),
|
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||||
'siteId' in contentPointer
|
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||||
? getCurrentSiteCustomization(contentPointer)
|
|
||||||
: getSpaceCustomization(contentPointer.spaceId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -57,29 +57,29 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
/**
|
/**
|
||||||
* Render a space as a standalone HTML page that can be printed as a PDF.
|
* Render a space as a standalone HTML page that can be printed as a PDF.
|
||||||
*/
|
*/
|
||||||
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
|
export default async function PDFHTMLOutput(props: {
|
||||||
const contentPointer = getContentPointer();
|
searchParams: Promise<{ [key: string]: string }>;
|
||||||
|
}) {
|
||||||
|
const pointer = await getSiteOrSpacePointerForPDF();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||||
|
|
||||||
// Build current PDF URL and preserve all search params
|
// Build current PDF URL and preserve all search params
|
||||||
let currentPDFUrl = absoluteHref('~gitbook/pdf', true);
|
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
|
||||||
currentPDFUrl += '?' + searchParams.toString();
|
currentPDFUrl += '?' + searchParams.toString();
|
||||||
|
|
||||||
// Load the content,
|
// Load the content,
|
||||||
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
|
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
|
||||||
'siteId' in contentPointer
|
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||||
? getCurrentSiteCustomization(contentPointer)
|
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||||
: getSpaceCustomization(contentPointer.spaceId),
|
|
||||||
getSpaceContentData(contentPointer),
|
|
||||||
]);
|
]);
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
|
|
||||||
// Compute the pages to render
|
// Compute the pages to render
|
||||||
const { pages, total } = selectPages(rootPages, pdfParams);
|
const { pages, total } = selectPages(rootPages, pdfParams);
|
||||||
const pageIds = pages.map(
|
const pageIds = pages.map(
|
||||||
({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
|
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string],
|
||||||
);
|
);
|
||||||
const linksContext: PageHrefContext = {
|
const linksContext: PageHrefContext = {
|
||||||
pdf: pages.map(({ page }) => page.id),
|
pdf: pages.map(({ page }) => page.id),
|
||||||
@@ -91,17 +91,16 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
|
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
|
||||||
<a
|
<a
|
||||||
title={tString(language, 'pdf_goback')}
|
title={tString(language, 'pdf_goback')}
|
||||||
href={pdfParams.back ?? absoluteHref('')}
|
href={pdfParams.back ?? (await getAbsoluteHref(''))}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex',
|
'flex',
|
||||||
'flex-row',
|
'flex-row',
|
||||||
'items-center',
|
'items-center',
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'text-sm',
|
'text-sm',
|
||||||
'text-dark/6',
|
'text-tint',
|
||||||
'hover:text-primary',
|
'hover:text-primary',
|
||||||
'p-4',
|
'p-4',
|
||||||
'dark:text-light/5',
|
|
||||||
'rounded-full',
|
'rounded-full',
|
||||||
'bg-white',
|
'bg-white',
|
||||||
'shadow-sm',
|
'shadow-sm',
|
||||||
@@ -110,7 +109,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
'border',
|
'border',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowLeft className={tcls('size-6')} />
|
<Icon icon="arrow-left" className={tcls('size-6')} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -124,10 +123,9 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
'items-center',
|
'items-center',
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'text-sm',
|
'text-sm',
|
||||||
'text-dark/6',
|
'text-tint',
|
||||||
'hover:text-primary',
|
'hover:text-primary',
|
||||||
'p-4',
|
'p-4',
|
||||||
'dark:text-light/5',
|
|
||||||
'rounded-full',
|
'rounded-full',
|
||||||
'bg-white',
|
'bg-white',
|
||||||
'shadow-sm',
|
'shadow-sm',
|
||||||
@@ -136,7 +134,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
'border',
|
'border',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Printer className={tcls('size-6')} />
|
<Icon icon="print" className={tcls('size-6')} />
|
||||||
</PrintButton>
|
</PrintButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,7 +146,11 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
limit={pdfParams.limit ?? DEFAULT_LIMIT}
|
limit={pdfParams.limit ?? DEFAULT_LIMIT}
|
||||||
trademark={
|
trademark={
|
||||||
customization.trademark.enabled ? (
|
customization.trademark.enabled ? (
|
||||||
<TrademarkLink space={space} customization={customization} />
|
<TrademarkLink
|
||||||
|
space={space}
|
||||||
|
customization={customization}
|
||||||
|
placement={SiteInsightsTrademarkPlacement.Pdf}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -161,7 +163,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
<React.Suspense
|
<React.Suspense
|
||||||
key={page.id}
|
key={page.id}
|
||||||
fallback={
|
fallback={
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(page)}>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</PrintPage>
|
</PrintPage>
|
||||||
}
|
}
|
||||||
@@ -170,6 +172,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
space={space}
|
space={space}
|
||||||
page={page}
|
page={page}
|
||||||
refContext={{
|
refContext={{
|
||||||
|
siteContext: 'siteId' in pointer ? pointer : null,
|
||||||
space,
|
space,
|
||||||
revisionId: contentTarget.revisionId,
|
revisionId: contentTarget.revisionId,
|
||||||
pages: rootPages,
|
pages: rootPages,
|
||||||
@@ -205,7 +208,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
|
|||||||
const { page } = props;
|
const { page } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(page)}>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'break-before-page',
|
'break-before-page',
|
||||||
@@ -229,12 +232,10 @@ async function PDFPageDocument(props: {
|
|||||||
refContext: ContentRefContext;
|
refContext: ContentRefContext;
|
||||||
}) {
|
}) {
|
||||||
const { space, page, refContext } = props;
|
const { space, page, refContext } = props;
|
||||||
|
const document = await getPageDocument(space.id, page);
|
||||||
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
|
|
||||||
const shouldHighlightCode = createHighlightingContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(page)}>
|
||||||
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
|
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
|
||||||
{page.description ? (
|
{page.description ? (
|
||||||
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
|
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
|
||||||
@@ -253,8 +254,7 @@ async function PDFPageDocument(props: {
|
|||||||
},
|
},
|
||||||
contentRefContext: refContext,
|
contentRefContext: refContext,
|
||||||
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
|
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
|
||||||
getId: (id) => pagePDFContainerId(page, id),
|
getId: (id) => getPagePDFContainerId(page, id),
|
||||||
shouldHighlightCode,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -315,7 +315,7 @@ function selectPages(
|
|||||||
return [
|
return [
|
||||||
{ page, depth },
|
{ page, depth },
|
||||||
...page.pages.flatMap((child) => {
|
...page.pages.flatMap((child) => {
|
||||||
if (child.type === 'link') {
|
if (child.type !== 'document') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ function selectPages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allPages = rootPages.flatMap((page) => {
|
const allPages = rootPages.flatMap((page) => {
|
||||||
if (page.type === 'link') {
|
if (page.type !== 'document' && page.type !== 'group') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 async function getSiteOrSpacePointerForPDF(): Promise<
|
||||||
|
SiteContentPointer | SpaceContentPointer
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
return await getSiteContentPointer();
|
||||||
|
} catch (error) {
|
||||||
|
return getSpacePointer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// no-cors, *cors, same-origin
|
// no-cors, *cors, same-origin
|
||||||
// mode: 'cors', // Not supported on Cloudflare Workers
|
// mode: 'cors', // Not supported on Cloudflare Workers
|
||||||
// *default, no-cache, reload, force-cache, only-if-cached
|
// *default, no-cache, reload, force-cache, only-if-cached
|
||||||
cache: 'no-cache',
|
// cache: 'no-cache', // Not supported on Cloudflare Workers
|
||||||
// include, *same-origin, omit
|
// include, *same-origin, omit
|
||||||
// credentials: 'include', // Not supported on Cloudflare Workers
|
// credentials: 'include', // Not supported on Cloudflare Workers
|
||||||
headers: requestBody.headers,
|
headers: requestBody.headers,
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||||
|
|
||||||
|
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
|
||||||
|
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
const response = await withMiddlewareHeadersStorage(() =>
|
||||||
|
nextOnPagesHandler.fetch(request, env, ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
} as ExportedHandler<{ ASSETS: Fetcher }>;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Space } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||||
|
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||||
|
import { DateRelative } from '../primitives';
|
||||||
|
|
||||||
|
interface AdminToolbarProps {
|
||||||
|
content: SiteContentPointer;
|
||||||
|
space: Space;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'fixed',
|
||||||
|
'bottom-5',
|
||||||
|
'left-1/2',
|
||||||
|
'z-50',
|
||||||
|
'transform',
|
||||||
|
'-translate-x-1/2',
|
||||||
|
'rounded-full',
|
||||||
|
|
||||||
|
'bg-tint-12/9',
|
||||||
|
'dark:bg-tint-1/9',
|
||||||
|
|
||||||
|
'shadow-lg',
|
||||||
|
'min-h-10',
|
||||||
|
'min-w-40',
|
||||||
|
'p-2',
|
||||||
|
'max-w-md',
|
||||||
|
'border-tint-12/1',
|
||||||
|
'backdrop-blur-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<React.Suspense fallback={null}>{props.children}</React.Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||||
|
*/
|
||||||
|
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||||
|
const { content } = props;
|
||||||
|
const mode = await headers().get('x-gitbook-mode');
|
||||||
|
|
||||||
|
if (mode === 'multi-id') {
|
||||||
|
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.changeRequestId) {
|
||||||
|
return (
|
||||||
|
<ChangeRequestToolbar
|
||||||
|
spaceId={content.spaceId}
|
||||||
|
changeRequestId={content.changeRequestId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.revisionId) {
|
||||||
|
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
|
||||||
|
const { spaceId, changeRequestId } = props;
|
||||||
|
|
||||||
|
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
|
||||||
|
|
||||||
|
if (!changeRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarLayout>
|
||||||
|
<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-tint-2 dark:text-tint-11">
|
||||||
|
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||||
|
</p>
|
||||||
|
</ToolbarBody>
|
||||||
|
<ToolbarButtonGroups>
|
||||||
|
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||||
|
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<RefreshChangeRequestButton
|
||||||
|
spaceId={spaceId}
|
||||||
|
changeRequestId={changeRequestId}
|
||||||
|
revisionId={changeRequest.revision}
|
||||||
|
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
||||||
|
/>
|
||||||
|
</ToolbarButtonGroups>
|
||||||
|
</Toolbar>
|
||||||
|
</ToolbarLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
|
||||||
|
const { spaceId, revisionId } = props;
|
||||||
|
|
||||||
|
const revision = await getRevision(spaceId, revisionId, {
|
||||||
|
metadata: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarLayout>
|
||||||
|
<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-tint-2 dark:text-tint-11">
|
||||||
|
{revision.git.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</ToolbarBody>
|
||||||
|
<ToolbarButtonGroups>
|
||||||
|
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||||
|
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
{revision.git?.url ? (
|
||||||
|
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||||
|
<Icon
|
||||||
|
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
</ToolbarButton>
|
||||||
|
) : null}
|
||||||
|
</ToolbarButtonGroups>
|
||||||
|
</Toolbar>
|
||||||
|
</ToolbarLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { ToolbarButton } from './Toolbar';
|
||||||
|
|
||||||
|
// We don't show the button if the content has been updated 30s ago or less.
|
||||||
|
const minInterval = 1000 * 30; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button to refresh the page if the content has been updated.
|
||||||
|
*/
|
||||||
|
export function RefreshChangeRequestButton(props: {
|
||||||
|
spaceId: string;
|
||||||
|
changeRequestId: string;
|
||||||
|
revisionId: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}) {
|
||||||
|
const { updatedAt } = props;
|
||||||
|
|
||||||
|
const [visible, setVisible] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const checkForUpdates = useCheckForContentUpdate(props);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await checkForUpdates();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
|
// Show the button if the content has been updated more than 30s ago.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (updatedAt < Date.now() - minInterval) {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}, [updatedAt]);
|
||||||
|
|
||||||
|
// 30sec after being hidden, we show the button again
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setVisible(true);
|
||||||
|
}, minInterval);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
title="Refresh"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
||||||
|
</ToolbarButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
export function Toolbar(props: { children: React.ReactNode }) {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-row',
|
||||||
|
'items-center',
|
||||||
|
'gap-4',
|
||||||
|
'text-sm',
|
||||||
|
'px-4',
|
||||||
|
'py-1',
|
||||||
|
'rounded-full',
|
||||||
|
'truncate',
|
||||||
|
'text-tint-1',
|
||||||
|
'dark:text-tint-12',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||||
|
return <div className="flex flex-col gap-1">{props.children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
||||||
|
return <div className="flex flex-row gap-2">{props.children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
||||||
|
const { children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...rest}
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-col',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'size-11',
|
||||||
|
'gap-1',
|
||||||
|
'text-sm',
|
||||||
|
'rounded-full',
|
||||||
|
'truncate',
|
||||||
|
'text-tint-1', // Equal to text-tint-strong in dark mode
|
||||||
|
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
|
||||||
|
'hover:bg-tint-12',
|
||||||
|
'dark:hover:bg-tint-1',
|
||||||
|
'hover:shadow-lg',
|
||||||
|
'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SiteAds,
|
||||||
|
SiteAdsStatus,
|
||||||
|
SiteInsightsAdPlacement,
|
||||||
|
SiteInsightsAd,
|
||||||
|
SiteInsightsTrademarkPlacement,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { t, useLanguage } from '@/intl/client';
|
||||||
|
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { renderAd } from './renderAd';
|
||||||
|
import { useHasBeenInViewport } from '../hooks/useHasBeenInViewport';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { Link } from '../primitives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone ID provided by BuySellAds for the preview.
|
||||||
|
*/
|
||||||
|
const PREVIEW_ZONE_ID = 'CVAIKKQM';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and render the Ad placement.
|
||||||
|
* https://docs.buysellads.com/ad-serving-api
|
||||||
|
*/
|
||||||
|
export function Ad({
|
||||||
|
zoneId,
|
||||||
|
spaceId,
|
||||||
|
placement,
|
||||||
|
ignore,
|
||||||
|
siteAdsStatus,
|
||||||
|
style,
|
||||||
|
mode = 'auto',
|
||||||
|
}: {
|
||||||
|
zoneId: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
placement: SiteInsightsAdPlacement;
|
||||||
|
ignore: boolean;
|
||||||
|
style?: ClassValue;
|
||||||
|
siteAdsStatus?: SiteAds['status'];
|
||||||
|
mode?: 'classic' | 'auto' | 'cover';
|
||||||
|
}) {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [ad, setAd] = React.useState<
|
||||||
|
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
|
||||||
|
>(undefined);
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
|
// Track display of the ad
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ad?.insightsAd) {
|
||||||
|
trackEvent({
|
||||||
|
type: 'ad_display',
|
||||||
|
ad: ad.insightsAd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ad, trackEvent]);
|
||||||
|
|
||||||
|
const hasBeenInViewport = useHasBeenInViewport(containerRef, { threshold: 0.1 });
|
||||||
|
|
||||||
|
// When the container is visible,
|
||||||
|
// track an impression on the ad and fetch it
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasBeenInViewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const previewParam = new URL(window.location.href).searchParams.get('ads_preview');
|
||||||
|
const preview = !!previewParam;
|
||||||
|
const realZoneId = preview ? PREVIEW_ZONE_ID : zoneId;
|
||||||
|
const showPlaceholderAd =
|
||||||
|
previewParam === 'placeholder' ||
|
||||||
|
(siteAdsStatus &&
|
||||||
|
(siteAdsStatus === SiteAdsStatus.Pending ||
|
||||||
|
siteAdsStatus === SiteAdsStatus.InReview));
|
||||||
|
|
||||||
|
if (!realZoneId && !showPlaceholderAd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const result = showPlaceholderAd
|
||||||
|
? await renderAd({ source: 'placeholder' })
|
||||||
|
: realZoneId
|
||||||
|
? await renderAd({
|
||||||
|
placement,
|
||||||
|
ignore: ignore || preview,
|
||||||
|
zoneId: realZoneId,
|
||||||
|
mode,
|
||||||
|
source: 'live',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setAd(result);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||||
|
{ad ? (
|
||||||
|
<>
|
||||||
|
{ad.children}
|
||||||
|
<AdSponsoredLink spaceId={spaceId} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdSponsoredLink(props: { spaceId: string }) {
|
||||||
|
const { spaceId } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
|
const viaUrl = new URL('https://www.gitbook.com');
|
||||||
|
viaUrl.searchParams.set('utm_source', 'content');
|
||||||
|
viaUrl.searchParams.set('utm_medium', 'sponsored-by-gitbook');
|
||||||
|
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
href={viaUrl.toString()}
|
||||||
|
className={tcls('hover:underline')}
|
||||||
|
insights={{
|
||||||
|
type: 'trademark_click',
|
||||||
|
placement: SiteInsightsTrademarkPlacement.Ad,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(language, 'sponsored_via_gitbook')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { SiteInsightsAd } from '@gitbook/api';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { getResizedImageURL } from '@/lib/images';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { AdItem } from './types';
|
||||||
|
import { Link } from '../primitives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic rendering for an ad.
|
||||||
|
*/
|
||||||
|
export async function AdClassicRendering({
|
||||||
|
ad,
|
||||||
|
insightsAd,
|
||||||
|
}: {
|
||||||
|
ad: AdItem;
|
||||||
|
insightsAd: SiteInsightsAd | null;
|
||||||
|
}) {
|
||||||
|
const smallImgSrc =
|
||||||
|
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
|
||||||
|
const logoSrc =
|
||||||
|
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
rel="sponsored noopener"
|
||||||
|
target="_blank"
|
||||||
|
insights={
|
||||||
|
insightsAd
|
||||||
|
? {
|
||||||
|
type: 'ad_click',
|
||||||
|
ad: insightsAd,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-col',
|
||||||
|
'gap-4',
|
||||||
|
'bg-tint',
|
||||||
|
'text-tint',
|
||||||
|
'hover:text-tint-strong',
|
||||||
|
'rounded-lg',
|
||||||
|
'p-4',
|
||||||
|
)}
|
||||||
|
href={ad.statlink}
|
||||||
|
>
|
||||||
|
{smallImgSrc && 'smallImage' in ad ? (
|
||||||
|
<div>
|
||||||
|
<img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
|
||||||
|
</div>
|
||||||
|
) : logoSrc && 'logo' in ad ? (
|
||||||
|
<div
|
||||||
|
className={tcls('px-6', 'py-4', 'rounded-md')}
|
||||||
|
style={{ backgroundColor: ad.backgroundColor }}
|
||||||
|
>
|
||||||
|
<img alt="Ads logo" src={logoSrc} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={tcls('flex', 'flex-col')}>
|
||||||
|
<div className={tcls('text-xs')}>{ad.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-13
@@ -1,28 +1,46 @@
|
|||||||
|
import { SiteInsightsAd } from '@gitbook/api';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { hexToRgba } from '@/lib/colors';
|
import { hexToRgba } from '@/lib/colors';
|
||||||
|
import { getResizedImageURL } from '@/lib/images';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { AdCover } from './types';
|
import { AdCover } from './types';
|
||||||
|
import { Link } from '../primitives';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cover rendering for an ad.
|
* Cover rendering for an ad.
|
||||||
*/
|
*/
|
||||||
export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
export async function AdCoverRendering({
|
||||||
|
ad,
|
||||||
|
insightsAd,
|
||||||
|
}: {
|
||||||
|
ad: AdCover;
|
||||||
|
insightsAd: SiteInsightsAd | null;
|
||||||
|
}) {
|
||||||
|
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
|
rel="sponsored noopener"
|
||||||
|
target="_blank"
|
||||||
|
insights={
|
||||||
|
insightsAd
|
||||||
|
? {
|
||||||
|
type: 'ad_click',
|
||||||
|
ad: insightsAd,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'group/ad',
|
'group/ad',
|
||||||
'relative',
|
'relative',
|
||||||
'flex',
|
'flex',
|
||||||
'flex-col',
|
'flex-col',
|
||||||
'gap-4',
|
'gap-4',
|
||||||
'bg-light-2',
|
'bg-tint-subtle',
|
||||||
'text-dark/7',
|
'text-tint',
|
||||||
'dark:bg-dark-2',
|
'hover:text-tint-strong',
|
||||||
'dark:text-light/7',
|
|
||||||
'hover:text-dark/9',
|
|
||||||
'dark:hover:text-light/9',
|
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
'p-4',
|
'p-4',
|
||||||
'overflow-hidden',
|
'overflow-hidden',
|
||||||
@@ -30,8 +48,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
|||||||
)}
|
)}
|
||||||
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
||||||
href={ad.statlink}
|
href={ad.statlink}
|
||||||
rel="sponsored noopener"
|
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
@@ -43,14 +59,14 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
|||||||
'z-0',
|
'z-0',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${ad.largeImage})`,
|
backgroundImage: `url(${largeImage})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={tcls('z-[2]')}>
|
<div className={tcls('z-[2]')}>
|
||||||
<img
|
<img
|
||||||
alt="Large image"
|
alt="Large image"
|
||||||
src={ad.largeImage}
|
src={largeImage}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'rounded-md',
|
'rounded-md',
|
||||||
'shadow-md',
|
'shadow-md',
|
||||||
@@ -103,6 +119,6 @@ export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
|||||||
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render attribution or verification pixels.
|
||||||
|
* https://docs.buysellads.com/ad-serving-api#pixels
|
||||||
|
*/
|
||||||
|
export function AdPixels({ rawPixel }: { rawPixel: string }) {
|
||||||
|
const pixels = rawPixel.split('||');
|
||||||
|
const time = String(Math.round(Date.now() / 1e4) | 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={tcls('hidden')}>
|
||||||
|
{pixels.map((pixel, index) => {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={pixel.replace('[timestamp]', time)}
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
alt="Ads tracking pixel"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user